Files
wehub-resource-sync 97e91a83f3
Ruff / Ruff (push) Waiting to run
Test / Core Tests (push) Waiting to run
Test / Offline Coverage Tests (Python 3.10) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.11) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.12) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.13) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.9) (push) Waiting to run
Test / Full Coverage (Python 3.11) (push) Waiting to run
Test / Core Provider Tests (OpenAI) (push) Blocked by required conditions
Test / Core Provider Tests (Anthropic) (push) Blocked by required conditions
Test / Core Provider Tests (Google) (push) Blocked by required conditions
Test / Core Provider Tests (Other) (push) Blocked by required conditions
Test / Anthropic Tests (push) Blocked by required conditions
Test / Gemini Tests (push) Blocked by required conditions
Test / Google GenAI Tests (push) Blocked by required conditions
Test / Vertex AI Tests (push) Blocked by required conditions
Test / OpenAI Tests (push) Blocked by required conditions
Test / Writer Tests (push) Blocked by required conditions
Test / Auto Client Tests (push) Blocked by required conditions
ty / type-check (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:36:38 +08:00

959 lines
34 KiB
Python

from __future__ import annotations
import builtins
import sys
from collections.abc import AsyncGenerator, Generator, Iterable
from types import ModuleType, SimpleNamespace
from typing import Any, cast
import pytest
from google.genai import types
from pydantic import BaseModel, ValidationError
from instructor.v2.core.client import AsyncInstructor, Instructor
from instructor.v2.core.errors import (
ClientError,
ConfigurationError,
ModeError,
ResponseParsingError,
)
from instructor.v2.core.mode import Mode
from instructor.v2.core.providers import Provider
from instructor.v2.dsl.iterable import IterableBase, IterableModel
from instructor.v2.dsl.partial import Partial, PartialBase
from instructor.v2.dsl.simple_type import ModelAdapter
from instructor.v2.providers import gemini
from instructor.v2.providers.gemini import client as gemini_client
from instructor.v2.providers.gemini import handlers, schema, templating, utils
class Answer(BaseModel):
"""A small structured answer returned by Gemini."""
value: int
class LegacyModel:
def __init__(self) -> None:
self.sync_requests: list[dict[str, Any]] = []
self.async_requests: list[dict[str, Any]] = []
def generate_content(self, **kwargs: Any) -> Any:
self.sync_requests.append(kwargs)
return SimpleNamespace(text='{"value": 7}')
async def generate_content_async(self, **kwargs: Any) -> Any:
self.async_requests.append(kwargs)
return SimpleNamespace(text='{"value": 9}')
class FunctionCall:
def __init__(self, *, name: str = "Answer", args: Any = None) -> None:
self.name = name
self.args = args
@classmethod
def to_dict(cls, call: FunctionCall) -> dict[str, Any]:
return {"name": call.name, "args": call.args}
class DictOnlyFunctionCall:
args = None
@classmethod
def to_dict(cls, _call: DictOnlyFunctionCall) -> dict[str, Any]:
return {"name": "Answer", "args": {"value": "11"}}
class BrokenFunctionCall:
args = None
@classmethod
def to_dict(cls, _call: BrokenFunctionCall) -> dict[str, Any]:
raise ValueError("invalid function-call payload")
class NameOnlyFunctionCall:
@classmethod
def to_dict(cls, _call: NameOnlyFunctionCall) -> dict[str, Any]:
return {"name": "Answer"}
class UnexpectedFunctionCallError:
args = None
@classmethod
def to_dict(cls, _call: UnexpectedFunctionCallError) -> dict[str, Any]:
raise RuntimeError("unexpected conversion failure")
class UnexpectedCompletionError:
@property
def candidates(self) -> list[Any]:
raise RuntimeError("unexpected candidate failure")
class Part:
def __init__(
self,
*,
text: str | None = None,
function_call: Any = None,
function_response: Any = None,
) -> None:
self.text = text
self.function_call = function_call
self.function_response = function_response
class Completion:
def __init__(
self,
*,
text: str | None = None,
part_text: str | None = None,
function_call: Any = None,
text_error: type[Exception] | None = None,
) -> None:
self._text = text
self._text_error = text_error
part = Part(text=part_text, function_call=function_call)
self.parts = [part]
self.candidates = [SimpleNamespace(content=SimpleNamespace(parts=[part]))]
@property
def text(self) -> str | None:
if self._text_error is not None:
raise self._text_error("response text is unavailable")
return self._text
def _install_legacy_types(monkeypatch: pytest.MonkeyPatch) -> None:
class FunctionResponse:
def __init__(self, *, name: str, response: dict[str, Any]) -> None:
self.name = name
self.response = response
class FunctionDeclaration:
def __init__(
self,
*,
name: str,
description: str,
parameters: dict[str, Any],
) -> None:
self.name = name
self.description = description
self.parameters = parameters
glm = ModuleType("google.ai.generativelanguage")
vars(glm)["FunctionCall"] = FunctionCall
vars(glm)["FunctionResponse"] = FunctionResponse
vars(glm)["Part"] = Part
google_ai = ModuleType("google.ai")
vars(google_ai)["generativelanguage"] = glm
legacy_types = ModuleType("google.generativeai.types")
vars(legacy_types)["FunctionDeclaration"] = FunctionDeclaration
legacy = ModuleType("google.generativeai")
vars(legacy)["types"] = legacy_types
monkeypatch.setitem(sys.modules, "google.ai", google_ai)
monkeypatch.setitem(sys.modules, "google.ai.generativelanguage", glm)
monkeypatch.setitem(sys.modules, "google.generativeai", legacy)
monkeypatch.setitem(sys.modules, "google.generativeai.types", legacy_types)
def test_gemini_package_exports_factory_lazily_and_rejects_unknown_name() -> None:
assert gemini.__getattr__("from_gemini") is gemini_client.from_gemini
with pytest.raises(AttributeError, match="missing_factory"):
gemini.__getattr__("missing_factory")
def test_gemini_factory_reports_unsupported_mode_and_missing_or_invalid_client(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with pytest.raises(ModeError) as mode_error:
gemini_client.from_gemini(object(), mode=Mode.JSON_SCHEMA)
assert mode_error.value.provider == Provider.GEMINI.value
assert Mode.JSON_SCHEMA.value == mode_error.value.mode
assert Mode.TOOLS.value in mode_error.value.valid_modes
monkeypatch.setattr(gemini_client, "genai", None)
with pytest.raises(ClientError, match="google-generativeai is not installed"):
gemini_client.from_gemini(object())
monkeypatch.setattr(
gemini_client, "genai", SimpleNamespace(GenerativeModel=LegacyModel)
)
with pytest.raises(ClientError, match="Got: object"):
gemini_client.from_gemini(object())
monkeypatch.setattr(gemini_client, "genai", SimpleNamespace())
with pytest.raises(ClientError, match="genai.GenerativeModel"):
gemini_client.from_gemini(LegacyModel())
@pytest.mark.asyncio
async def test_gemini_factory_patches_sync_and_async_generation_locally(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
gemini_client, "genai", SimpleNamespace(GenerativeModel=LegacyModel)
)
sync_native = LegacyModel()
sync_client = gemini_client.from_gemini(
sync_native, mode=Mode.MD_JSON, trace_id="sync-request"
)
assert isinstance(sync_client, Instructor)
assert sync_client.client is sync_native
assert sync_client.provider is Provider.GEMINI
assert sync_client.mode is Mode.MD_JSON
sync_result = sync_client.create(
response_model=Answer,
messages=[{"role": "user", "content": "return seven"}],
max_retries=1,
)
assert sync_result.value == 7
assert sync_native.sync_requests[0]["trace_id"] == "sync-request"
assert sync_native.sync_requests[0]["generation_config"]["response_mime_type"] == (
"application/json"
)
assert sync_native.sync_requests[0]["contents"][0]["parts"][-1] == "return seven"
async_native = LegacyModel()
async_client = gemini_client.from_gemini(
async_native, mode=Mode.MD_JSON, use_async=True, trace_id="async-request"
)
assert isinstance(async_client, AsyncInstructor)
assert async_client.client is async_native
assert async_client.provider is Provider.GEMINI
assert async_client.mode is Mode.MD_JSON
async_result = await async_client.create(
response_model=Answer,
messages=[{"role": "user", "content": "return nine"}],
max_retries=1,
)
assert async_result.value == 9
assert async_native.async_requests[0]["trace_id"] == "async-request"
assert async_native.async_requests[0]["contents"][0]["parts"][-1] == "return nine"
def test_legacy_schema_generation_maps_optional_and_enum_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_legacy_types(monkeypatch)
schema.generate_gemini_schema.cache_clear()
class Choice(BaseModel):
"""A documented legacy function."""
status: str | None
category: str
with pytest.warns(DeprecationWarning, match="generate_gemini_schema is deprecated"):
declaration = schema.generate_gemini_schema(Choice)
assert declaration.name == "Choice"
assert declaration.description == "A documented legacy function."
assert declaration.parameters["properties"]["status"]["nullable"] is True
assert declaration.parameters["properties"]["status"]["type"] == "string"
assert set(declaration.parameters["required"]) == {"status", "category"}
schema.generate_gemini_schema.cache_clear()
def test_legacy_schema_generation_gives_an_actionable_missing_sdk_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
schema.generate_gemini_schema.cache_clear()
def missing_legacy_types(_name: str) -> Any:
raise ImportError("legacy Gemini SDK missing")
monkeypatch.setattr(schema.importlib, "import_module", missing_legacy_types)
with pytest.warns(DeprecationWarning):
with pytest.raises(ImportError, match="Please install google-genai instead"):
schema.generate_gemini_schema(Answer)
schema.generate_gemini_schema.cache_clear()
def test_gemini_reask_messages_preserve_bad_arguments_and_validation_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_legacy_types(monkeypatch)
original = [{"role": "user", "parts": ["extract a value"]}]
response = Completion(
function_call=FunctionCall(name="Answer", args={"value": "bad"})
)
error = ValueError("value must be an integer")
result = handlers.GeminiToolsHandler().handle_reask(
{"contents": original.copy()}, response, error
)
assert result["contents"][0] == original[0]
model_call = result["contents"][1]
assert model_call["role"] == "model"
assert model_call["parts"][0].name == "Answer"
assert model_call["parts"][0].args == {"value": "bad"}
function_result = result["contents"][2]["parts"][0].function_response
assert function_result.name == "Answer"
assert function_result.response == {
"error": "Validation Error(s) found:\nvalue must be an integer"
}
assert result["contents"][3]["role"] == "user"
assert "fix the errors" in result["contents"][3]["parts"][0]
json_response = Completion(text='{"value": "bad"}')
json_result = handlers.GeminiJSONHandler().handle_reask(
{"contents": original.copy()}, json_response, error
)
assert json_result["contents"][-1]["role"] == "user"
assert '{"value": "bad"}' in json_result["contents"][-1]["parts"][0]
assert "value must be an integer" in json_result["contents"][-1]["parts"][0]
def test_gemini_parsers_handle_strict_json_and_blocked_or_bad_tool_responses() -> None:
strict_response = Completion(text='```json\n{"value": 3}\n```')
parsed = handlers.parse_gemini_json(Answer, strict_response, strict=True)
assert isinstance(parsed, Answer)
assert parsed.value == 3
with pytest.raises(ValidationError, match="valid integer"):
handlers.parse_gemini_json(
Answer, Completion(text='{"value": "3"}'), strict=True
)
blocked = Completion(text_error=ValueError)
with pytest.raises(ResponseParsingError) as blocked_error:
handlers.parse_gemini_json(Answer, blocked)
assert blocked_error.value.mode == "GEMINI_JSON"
assert blocked_error.value.raw_response is blocked
fallback = Completion(function_call=DictOnlyFunctionCall())
fallback_result = handlers.parse_gemini_tools(Answer, fallback, strict=False)
assert isinstance(fallback_result, Answer)
assert fallback_result.value == 11
missing_call = SimpleNamespace(candidates=[])
with pytest.raises(ResponseParsingError, match="No tool call found") as call_error:
handlers.parse_gemini_tools(Answer, missing_call)
assert call_error.value.mode == "GEMINI_TOOLS"
assert call_error.value.raw_response is missing_call
broken_args = Completion(function_call=BrokenFunctionCall())
with pytest.raises(
ResponseParsingError, match="No tool call args found"
) as args_error:
handlers.parse_gemini_tools(Answer, broken_args)
assert args_error.value.mode == "GEMINI_TOOLS"
assert args_error.value.raw_response is broken_args
with pytest.raises(RuntimeError, match="unexpected candidate failure"):
handlers.parse_gemini_tools(Answer, UnexpectedCompletionError())
with pytest.raises(RuntimeError, match="unexpected conversion failure"):
handlers.parse_gemini_tools(
Answer,
Completion(function_call=UnexpectedFunctionCallError()),
)
def test_gemini_handlers_prepare_expected_tool_and_json_requests(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(Answer, "gemini_schema", {"name": "Answer"}, raising=False)
original = {"messages": [{"role": "user", "content": "extract 4"}]}
tools_model, tools_request = handlers.GeminiToolsHandler().prepare_request(
Answer, original
)
json_model, json_request = handlers.GeminiJSONHandler().prepare_request(
Answer, original
)
assert tools_model is Answer
assert tools_request["tools"] == [{"name": "Answer"}]
assert tools_request["tool_config"]["function_calling_config"] == {
"mode": "ANY",
"allowed_function_names": ["Answer"],
}
assert json_model is Answer
assert json_request["generation_config"]["response_mime_type"] == "application/json"
assert original["messages"][0]["role"] == "system"
assert "json_schema" in original["messages"][0]["content"]
assert original["messages"][1] == {"role": "user", "content": "extract 4"}
def test_gemini_tools_handler_parses_and_finalizes_a_valid_tool_response() -> None:
response = Completion(function_call=FunctionCall(args={"value": 12}))
parsed = handlers.GeminiToolsHandler().parse_response(response, Answer, strict=True)
assert parsed.value == 12
assert parsed._raw_response is response
def test_gemini_stream_extractors_keep_valid_chunks_and_skip_incomplete_chunks() -> (
None
):
tool_handler = handlers.GeminiToolsHandler()
tool_chunks = [
Completion(function_call=FunctionCall(args={"value": 1})),
Completion(function_call=NameOnlyFunctionCall()),
SimpleNamespace(),
]
assert list(tool_handler.extract_streaming_json(tool_chunks)) == ['{"value": 1}']
json_handler = handlers.GeminiJSONHandler()
json_chunks = [
Completion(text='{"value":'),
Completion(part_text=" 2}", text_error=AttributeError),
Completion(part_text="", text_error=AttributeError),
SimpleNamespace(),
]
assert list(json_handler.extract_streaming_json(json_chunks)) == [
'{"value":',
" 2}",
]
@pytest.mark.asyncio
async def test_gemini_async_stream_extractors_keep_valid_chunks_and_skip_incomplete_chunks() -> (
None
):
async def stream(chunks: Iterable[Any]) -> AsyncGenerator[Any, None]:
for chunk in chunks:
yield chunk
tool_chunks = [
Completion(function_call=FunctionCall(args={"value": 1})),
Completion(function_call=NameOnlyFunctionCall()),
SimpleNamespace(),
]
assert [
chunk
async for chunk in handlers.GeminiToolsHandler().extract_streaming_json_async(
stream(tool_chunks)
)
] == ['{"value": 1}']
json_chunks = [
Completion(text='{"value":'),
Completion(part_text=" 2}", text_error=AttributeError),
Completion(part_text="", text_error=AttributeError),
SimpleNamespace(),
]
assert [
chunk
async for chunk in handlers.GeminiJSONHandler().extract_streaming_json_async(
stream(json_chunks)
)
] == ['{"value":', " 2}"]
@pytest.mark.asyncio
async def test_gemini_handlers_parse_iterable_partial_and_async_streams() -> None:
iterable_model = IterableModel(Answer)
assert issubclass(iterable_model, IterableBase)
tools_handler = handlers.GeminiToolsHandler()
tools_stream = [
Completion(function_call=FunctionCall(args={"tasks": [{"value": 1}]}))
]
tools_result = tools_handler.parse_response(
tools_stream,
iterable_model,
validation_context={"source": "tool-stream"},
strict=True,
stream=True,
)
assert isinstance(tools_result, Generator)
assert [item.value for item in tools_result] == [1]
partial_model = Partial[Answer]
assert issubclass(partial_model, PartialBase)
partial_result = handlers.GeminiJSONHandler().parse_response(
[Completion(text='{"value":'), Completion(text=" 2}")],
partial_model,
stream=True,
)
assert isinstance(partial_result, list)
assert partial_result[-1].value == 2
async def async_stream() -> AsyncGenerator[Any, None]:
yield Completion(text='{"tasks": [{"value": 4}]}')
async_result = handlers.GeminiJSONHandler().parse_response(
async_stream(),
iterable_model,
validation_context={"source": "async-stream"},
strict=True,
stream=True,
)
assert [item.value async for item in async_result] == [4]
def test_gemini_stream_parser_supports_custom_models_and_unwraps_simple_types() -> None:
class CustomStreamModel(BaseModel):
value: int
@classmethod
def from_streaming_response(
cls,
response: Iterable[Any],
stream_extractor: Any,
**kwargs: Any,
) -> Generator[CustomStreamModel, None, None]:
assert kwargs == {"context": {"source": "custom"}, "strict": False}
text = "".join(stream_extractor(response))
yield cls.model_validate_json(text)
handler = handlers.GeminiJSONHandler()
streamed = handler._parse_streaming(
CustomStreamModel,
[Completion(text='{"value": 5}')],
validation_context={"source": "custom"},
strict=False,
)
assert [item.value for item in streamed] == [5]
adapted = cast(type[BaseModel], ModelAdapter[int])
assert handler.parse_response(Completion(text='{"content": 6}'), adapted) == 6
parsed = handler.parse_response(Completion(text='{"value": 8}'), Answer)
assert parsed.value == 8
assert parsed._raw_response.text == '{"value": 8}'
raw_result = {"value": 9}
assert handler._finalize(Answer, Completion(), raw_result) is raw_result
def test_gemini_schema_mapping_handles_enums_optional_and_supported_unions() -> None:
mapped = utils.map_to_gemini_function_schema(
{
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["ready", "done"]},
"optional": {
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"score": {
"anyOf": [{"type": "string"}, {"type": "number"}],
},
"count": {
"anyOf": [{"type": "integer"}, {"type": "boolean"}],
},
},
"required": ["status", "optional", "score"],
}
)
status = mapped["properties"]["status"]
assert status == {"enum": ["ready", "done"], "format": "enum", "type": "string"}
assert mapped["properties"]["optional"] == {
"nullable": True,
"type": "string",
}
assert mapped["properties"]["score"]["anyOf"] == [
{"type": "string"},
{"type": "number"},
]
assert mapped["properties"]["count"]["anyOf"] == [
{"type": "integer"},
{"type": "boolean"},
]
def test_gemini_schema_mapping_surfaces_unsupported_union_validation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(utils, "verify_no_unions", lambda _schema: False)
with pytest.raises(ValueError, match="Gemini does not support Union types"):
utils.map_to_gemini_function_schema(Answer.model_json_schema())
def test_gemini_safety_defaults_fall_back_to_legacy_sdk_or_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_import = builtins.__import__
class LegacyCategory:
HARM_CATEGORY_HATE_SPEECH = "hate"
HARM_CATEGORY_HARASSMENT = "harassment"
HARM_CATEGORY_DANGEROUS_CONTENT = "dangerous"
class LegacyThreshold:
BLOCK_ONLY_HIGH = "high"
legacy_module = ModuleType("google.generativeai.types")
vars(legacy_module)["HarmCategory"] = LegacyCategory
vars(legacy_module)["HarmBlockThreshold"] = LegacyThreshold
def import_with_legacy(
name: str,
globals: Any = None,
locals: Any = None,
fromlist: Any = (),
level: int = 0,
) -> Any:
if name == "google.genai.types":
raise ImportError("new GenAI SDK missing")
if name == "google.generativeai.types":
return legacy_module
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", import_with_legacy)
utils._default_safety_thresholds.cache_clear()
assert utils._default_safety_thresholds() == {
"hate": "high",
"harassment": "high",
"dangerous": "high",
}
def import_without_google(
name: str,
globals: Any = None,
locals: Any = None,
fromlist: Any = (),
level: int = 0,
) -> Any:
if name in {"google.genai.types", "google.generativeai.types"}:
raise ImportError("Google SDK missing")
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", import_without_google)
utils._default_safety_thresholds.cache_clear()
assert utils._default_safety_thresholds() is None
utils._default_safety_thresholds.cache_clear()
def test_gemini_model_schema_and_generation_config_accept_compatibility_shapes() -> (
None
):
class SchemaHolder:
model_json_schema = {"type": "object", "properties": {}}
assert utils._get_model_schema(SchemaHolder) == {
"type": "object",
"properties": {},
}
safety_setting = types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=types.HarmBlockThreshold.BLOCK_ONLY_HIGH,
)
updated = utils.update_genai_kwargs(
{"safety_settings": [safety_setting]},
{"response_mime_type": "application/json"},
)
assert updated["response_mime_type"] == "application/json"
assert updated["safety_settings"] == [safety_setting]
config_object = SimpleNamespace(
thinking_config={"thinking_budget": 10}, labels={"suite": "coverage"}
)
inherited = utils.update_genai_kwargs({"config": config_object}, {})
assert inherited["thinking_config"] == {"thinking_budget": 10}
assert inherited["labels"] == {"suite": "coverage"}
assert "cached_content" not in inherited
with_null_token_limit = utils.update_genai_kwargs(
{"generation_config": {"max_tokens": None, "temperature": 0.25}}, {}
)
assert "max_output_tokens" not in with_null_token_limit
assert with_null_token_limit["temperature"] == 0.25
default_thresholds = utils.update_genai_kwargs({"safety_settings": ()}, {})
assert default_thresholds["safety_settings"]
assert all(
setting["threshold"] is types.HarmBlockThreshold.OFF
for setting in default_thresholds["safety_settings"]
)
legacy_kwargs = utils.update_gemini_kwargs(
{"generation_config": {"max_tokens": None, "temperature": 0.5}}
)
assert "contents" not in legacy_kwargs
assert "max_output_tokens" not in legacy_kwargs["generation_config"]
assert legacy_kwargs["generation_config"]["temperature"] == 0.5
def test_genai_message_conversion_rejects_invalid_roles_parts_and_message_types() -> (
None
):
assert (
utils.extract_genai_system_message(
cast(
list[dict[str, Any]],
[
"raw prompt",
{"role": "system", "content": "rules"},
{"role": "user", "content": "hi"},
],
)
)
== "rules\n\n"
)
assert utils.transform_to_gemini_prompt([]) == []
assert utils.transform_to_gemini_prompt(
cast(
Any,
[
{"role": "system", "content": None},
{
"role": "system",
"content": [
{"type": "image_url", "image_url": {"url": "image.png"}},
{"type": "text", "text": 42},
{"type": "text", "text": "use integers"},
],
},
{"role": "user", "content": "extract 4"},
],
)
) == [{"role": "user", "parts": ["*use integers*", "extract 4"]}]
assert (
utils.extract_genai_system_message(
cast(
list[dict[str, Any]],
[
42,
{"role": "system", "content": None},
{"role": "system", "content": ["use integers", object()]},
{"role": "user", "content": "extract 4"},
],
)
)
== "use integers\n\n"
)
assert (
utils.convert_to_genai_messages(
[
{"role": "system", "content": "rules"},
{"role": "user", "content": "hi"},
]
)[0]
.parts[0]
.text
== "hi"
)
with pytest.raises(ValueError, match="Unsupported role: assistant"):
utils.convert_to_genai_messages(
[{"role": "assistant", "content": "not a GenAI role"}]
)
with pytest.raises(ValueError, match="Unsupported content item type"):
utils.convert_to_genai_messages([{"role": "user", "content": [object()]}])
with pytest.raises(ValueError, match="Unsupported content type"):
utils.convert_to_genai_messages([{"role": "user", "content": 42}])
with pytest.raises(ValueError, match="Unsupported message type"):
utils.convert_to_genai_messages([cast(Any, 42)])
def test_gemini_request_helpers_support_unstructured_requests_and_reject_model_override() -> (
None
):
json_model, json_kwargs = utils.handle_gemini_json(
None,
{
"messages": [{"role": "user", "content": "hello"}],
"generation_config": {"max_tokens": 8},
},
)
assert json_model is None
assert json_kwargs["contents"] == [{"role": "user", "parts": ["hello"]}]
assert json_kwargs["generation_config"]["max_output_tokens"] == 8
with pytest.raises(ConfigurationError, match="must be set while patching"):
utils.handle_gemini_tools(Answer, {"model": "gemini-pro", "messages": []})
tools_model, tools_kwargs = utils.handle_gemini_tools(
None, {"messages": [{"role": "user", "content": "hello"}]}
)
assert tools_model is None
assert tools_kwargs["contents"] == [{"role": "user", "parts": ["hello"]}]
_, existing_system = utils.handle_gemini_json(
Answer,
{
"messages": [
{"role": "system", "content": "keep it brief"},
{"role": "user", "content": "hello"},
]
},
)
assert "keep it brief" in existing_system["contents"][0]["parts"][0]
assert "json_schema" in existing_system["contents"][0]["parts"][0]
def test_genai_request_helpers_cover_unstructured_streaming_and_config_inheritance() -> (
None
):
no_model, unstructured = utils.handle_genai_structured_outputs(
None,
{
"messages": [{"role": "user", "content": "hello"}],
"generation_config": {"max_tokens": 16},
},
)
assert no_model is None
assert unstructured["contents"][0].parts[0].text == "hello"
assert unstructured["config"].max_output_tokens == 16
_, explicit_system = utils.handle_genai_structured_outputs(
None,
{
"system": "use the explicit instructions",
"messages": [
{"role": "system", "content": "ignore these instructions"},
{"role": "user", "content": "hello"},
],
},
)
assert explicit_system["config"].system_instruction == (
"use the explicit instructions"
)
assert "system" not in explicit_system
structured_model, structured = utils.handle_genai_structured_outputs(
Answer,
{
"messages": [],
"stream": True,
"config": {"thinking_config": {"thinking_budget": 32}},
},
)
assert structured_model is not None
assert issubclass(structured_model, PartialBase)
assert structured["config"].response_mime_type == "application/json"
assert structured["config"].response_schema is structured_model
assert structured["config"].thinking_config.thinking_budget == 32
assert structured["config"].system_instruction is None
tool_model, tool_request = utils.handle_genai_tools(
Answer,
{
"messages": [],
"stream": True,
"config": {"thinking_config": {"thinking_budget": 64}},
},
)
assert tool_model is not None
assert issubclass(tool_model, PartialBase)
declaration = tool_request["config"].tools[0].function_declarations[0]
assert declaration.name == tool_model.__name__
assert declaration.parameters.properties["value"].type is types.Type.INTEGER
assert tool_request["config"].tool_config.function_calling_config.mode is (
types.FunctionCallingConfigMode.ANY
)
assert tool_request["config"].thinking_config.thinking_budget == 64
assert tool_request["config"].system_instruction is None
def test_genai_request_helpers_preserve_cached_content_and_system_messages_from_config() -> (
None
):
cached = types.GenerateContentConfig(
cached_content="cachedContents/session-1",
thinking_config=types.ThinkingConfig(thinking_budget=24),
)
messages = [
{"role": "system", "content": "use cached rules"},
{"role": "user", "content": "extract a value"},
]
structured_model, structured = utils.handle_genai_structured_outputs(
Answer, {"messages": messages.copy(), "config": cached}
)
assert structured_model is Answer
assert structured["config"].cached_content == "cachedContents/session-1"
assert structured["config"].thinking_config.thinking_budget == 24
assert structured["config"].system_instruction is None
assert structured["contents"][0].parts[0].text == "extract a value"
tool_model, tool_request = utils.handle_genai_tools(
Answer, {"messages": messages.copy(), "config": cached}
)
assert tool_model is Answer
assert tool_request["config"].cached_content == "cachedContents/session-1"
assert tool_request["config"].thinking_config.thinking_budget == 24
assert tool_request["config"].system_instruction is None
assert tool_request["config"].tools is None
assert tool_request["contents"][0].parts[0].text == "extract a value"
cached_only = SimpleNamespace(cached_content="cachedContents/session-2")
_, structured_cached_only = utils.handle_genai_structured_outputs(
Answer, {"messages": messages.copy(), "config": cached_only}
)
_, tools_cached_only = utils.handle_genai_tools(
Answer, {"messages": messages.copy(), "config": cached_only}
)
assert structured_cached_only["config"].cached_content == "cachedContents/session-2"
assert structured_cached_only["config"].thinking_config is None
assert tools_cached_only["config"].cached_content == "cachedContents/session-2"
assert tools_cached_only["config"].thinking_config is None
thinking_only = SimpleNamespace(
thinking_config=types.ThinkingConfig(thinking_budget=12)
)
_, structured_thinking_only = utils.handle_genai_structured_outputs(
Answer, {"messages": messages.copy(), "config": thinking_only}
)
_, tools_thinking_only = utils.handle_genai_tools(
Answer, {"messages": messages.copy(), "config": thinking_only}
)
assert structured_thinking_only["config"].cached_content is None
assert structured_thinking_only["config"].thinking_config.thinking_budget == 12
assert tools_thinking_only["config"].cached_content is None
assert tools_thinking_only["config"].thinking_config.thinking_budget == 12
def test_gemini_compatibility_helpers_keep_vertex_passthrough_and_genai_reask() -> None:
kwargs = {"messages": [{"role": "user", "content": "hello"}]}
vertex_model, vertex_tools = utils.handle_vertexai_tools(None, kwargs)
json_model, vertex_json = utils.handle_vertexai_json(None, kwargs)
assert vertex_model is None
assert vertex_tools is kwargs
assert json_model is None
assert vertex_json is kwargs
call = types.FunctionCall(name="Answer", args={"value": "wrong"})
model_content = types.Content(role="model", parts=[types.Part(function_call=call)])
response = SimpleNamespace(candidates=[SimpleNamespace(content=model_content)])
reasked = utils.reask_genai_tools(
{
"contents": [
types.Content(role="user", parts=[types.Part.from_text(text="hi")])
]
},
response,
ValueError("value must be an integer"),
)
assert reasked["contents"][1] is model_content
function_response = reasked["contents"][2].parts[0].function_response
assert function_response.name == "Answer"
assert "value must be an integer" in function_response.response["error"]
def test_gemini_templating_updates_text_parts_and_preserves_media_parts() -> None:
media = object()
message = {"role": "user", "parts": ["hello {{ name }}", media]}
result = templating.process_message(
message,
{"name": "Gemini"},
lambda text, context: text.replace("{{ name }}", context["name"]),
)
assert result is message
assert result["parts"] == ["hello Gemini", media]
assert templating.process_message({"role": "user"}, {}, lambda text, _: text) == {
"role": "user"
}