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

455 lines
16 KiB
Python

"""Offline coverage for the VertexAI v2 client, handlers, and helpers."""
from __future__ import annotations
import builtins
import importlib
import runpy
import sys
from collections.abc import Iterable
from types import SimpleNamespace
from typing import Any, Union, cast
import pytest
import vertexai.generative_models as gm
from pydantic import BaseModel
from instructor import Mode
from instructor.v2.core.client import AsyncInstructor, Instructor
from instructor.v2.core.errors import ClientError, ModeError
from instructor.v2.core.providers import Provider
from instructor.v2.dsl.iterable import IterableModel
from instructor.v2.dsl.partial import Partial
from instructor.v2.dsl.simple_type import ModelAdapter
from instructor.v2.providers.vertexai import handlers, templating
from instructor.v2.providers.vertexai.parallel import (
VertexAIParallelBase,
VertexAIParallelModel,
)
from tests.coverage._streams import async_items
vertex_client = importlib.import_module("instructor.v2.providers.vertexai.client")
class Weather(BaseModel):
"""Weather requested for a city."""
city: str
class Score(BaseModel):
"""A numeric score."""
value: int
def _part(
*,
name: str = "Weather",
args: dict[str, Any] | None = None,
text: str | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
function_call=SimpleNamespace(name=name, args=args or {}),
text=text,
)
def _response(*parts: Any, text: str = "") -> SimpleNamespace:
return SimpleNamespace(
text=text,
candidates=[SimpleNamespace(content=SimpleNamespace(parts=list(parts)))],
)
def test_vertexai_lazy_exports_resolve_and_unknown_exports_fail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
package = importlib.import_module("instructor.v2.providers.vertexai")
for name in package.__all__:
monkeypatch.delitem(package.__dict__, name, raising=False)
resolved = getattr(package, name)
module_path, attr_name = package._LAZY_ATTRS[name]
expected = getattr(
importlib.import_module(module_path, package.__name__), attr_name
)
assert resolved is expected
assert package.__dict__[name] is expected
with pytest.raises(AttributeError, match="not_exported"):
package.__getattr__("not_exported")
def test_vertexai_client_without_optional_sdk_has_install_hint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setitem(sys.modules, "vertexai.generative_models", None)
isolated = runpy.run_path(vertex_client.__file__, run_name="vertex_client_no_sdk")
assert isolated["gm"] is None
with pytest.raises(ClientError, match="pip install google-cloud-aiplatform"):
isolated["from_vertexai"](object(), mode=Mode.TOOLS)
def test_vertexai_client_validates_mode_and_client_type() -> None:
model = object.__new__(gm.GenerativeModel)
with pytest.raises(ModeError) as unsupported:
vertex_client.from_vertexai(model, mode=Mode.JSON)
assert unsupported.value.mode == Mode.JSON.value
assert unsupported.value.provider == Provider.VERTEXAI.value
assert Mode.TOOLS.value in unsupported.value.valid_modes
with pytest.raises(ClientError, match="Got: object"):
vertex_client.from_vertexai(cast(gm.GenerativeModel, object()), mode=Mode.TOOLS)
def test_vertexai_client_selects_and_patches_sync_and_async_methods(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = object.__new__(gm.GenerativeModel)
calls: list[tuple[str, Provider, Mode]] = []
def patch(*, func: Any, provider: Provider, mode: Mode) -> Any:
calls.append((func.__name__, provider, mode))
return func
monkeypatch.setattr(vertex_client, "patch_v2", patch)
sync_client = vertex_client.from_vertexai(model, mode=Mode.TOOLS)
async_client = vertex_client.from_vertexai(model, mode=Mode.MD_JSON, use_async=True)
assert isinstance(sync_client, Instructor)
assert isinstance(async_client, AsyncInstructor)
assert sync_client.client is model
assert async_client.client is model
assert (sync_client.provider, sync_client.mode) == (Provider.VERTEXAI, Mode.TOOLS)
assert (async_client.provider, async_client.mode) == (
Provider.VERTEXAI,
Mode.MD_JSON,
)
assert calls == [
("generate_content", Provider.VERTEXAI, Mode.TOOLS),
("generate_content_async", Provider.VERTEXAI, Mode.MD_JSON),
]
@pytest.mark.parametrize(
("blocked", "helper"),
[
("vertexai.generative_models", "_gm"),
("vertexai.preview.generative_models", "_tool_config_cls"),
],
)
def test_vertexai_handler_import_errors_explain_optional_dependency(
monkeypatch: pytest.MonkeyPatch, blocked: str, helper: str
) -> None:
original_import = builtins.__import__
def missing_sdk(name: str, *args: Any, **kwargs: Any) -> Any:
if name == blocked:
raise ImportError(f"No module named {name}")
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", missing_sdk)
with pytest.raises(ImportError, match="pip install google-cloud-aiplatform"):
getattr(handlers, helper)()
def test_vertexai_tools_build_one_declaration_per_parallel_model() -> None:
tool = handlers._create_vertexai_tool(Iterable[Union[Weather, Score]])
declarations = tool._raw_tool.function_declarations
assert [declaration.name for declaration in declarations] == ["Weather", "Score"]
assert declarations[0].description == "Weather requested for a city."
assert declarations[1].description == "A numeric score."
def test_vertexai_handlers_prepare_tool_json_and_parallel_requests() -> None:
request = {"messages": [{"role": "user", "content": "Weather in Paris?"}]}
tools_handler = handlers.VertexAIToolsHandler()
json_handler = handlers.VertexAIJSONHandler()
parallel_handler = handlers.VertexAIParallelToolsHandler()
tool_model, tool_request = tools_handler.prepare_request(Weather, request)
json_model, json_request = json_handler.prepare_request(Weather, request)
no_model, untouched = parallel_handler.prepare_request(None, request)
parallel_model, parallel_request = parallel_handler.prepare_request(
cast(type[BaseModel], Iterable[Weather]), request
)
assert tool_model is Weather
assert tool_request["contents"][0].parts[0].text == "Weather in Paris?"
assert tool_request["tools"][0]._raw_tool.function_declarations[0].name == "Weather"
assert (
tool_request["tool_config"]._gapic_tool_config.function_calling_config.mode.name
== "ANY"
)
assert json_model is Weather
assert json_request["contents"][0].parts[0].text == "Weather in Paris?"
assert (
json_request["generation_config"]._raw_generation_config.response_mime_type
== "application/json"
)
assert no_model is None
assert untouched == request
assert isinstance(parallel_model, VertexAIParallelBase)
assert list(parallel_model.registry) == ["Weather"]
assert (
parallel_request["tools"][0]._raw_tool.function_declarations[0].name
== "Weather"
)
def test_vertexai_handlers_parse_reask_and_finalize_responses() -> None:
tools_handler = handlers.VertexAIToolsHandler()
json_handler = handlers.VertexAIJSONHandler()
parallel_handler = handlers.VertexAIParallelToolsHandler()
tool_response = _response(_part(args={"city": "Paris"}))
json_response = _response(text='{"city": "Paris"}')
error = ValueError("city is required")
parsed_tool = tools_handler.parse_response(
tool_response, Weather, validation_context={"request_id": "tool"}
)
parsed_json = json_handler.parse_response(
json_response,
Weather,
validation_context={"request_id": "json"},
strict=True,
)
adapted = tools_handler.parse_response(
_response(_part(name="Response", args={"content": 7})),
cast(type[BaseModel], ModelAdapter[int]),
)
tool_retry = tools_handler.handle_reask({"contents": []}, tool_response, error)
json_retry = json_handler.handle_reask({"contents": []}, json_response, error)
parallel_retry = parallel_handler.handle_reask(
{"contents": []}, tool_response, error
)
assert parsed_tool == Weather(city="Paris")
assert parsed_tool._raw_response is tool_response
assert parsed_json == Weather(city="Paris")
assert parsed_json._raw_response is json_response
assert adapted == 7
assert len(tool_retry["contents"]) == 2
assert tool_retry["contents"][1].parts[0].function_response.name == "Weather"
assert "city is required" in json_retry["contents"][1].parts[0].text
assert len(parallel_retry["contents"]) == 2
def test_vertexai_sync_stream_extractors_skip_empty_chunks() -> None:
tools_handler = handlers.VertexAIToolsHandler()
json_handler = handlers.VertexAIJSONHandler()
tool_stream = [object(), _response(_part(args={"city": "Paris"}))]
json_stream = [object(), _response(_part(text='{"city":"Paris"}'))]
assert list(tools_handler.extract_streaming_json(tool_stream)) == [
'{"city": "Paris"}'
]
assert list(json_handler.extract_streaming_json(json_stream)) == [
'{"city":"Paris"}'
]
@pytest.mark.asyncio
async def test_vertexai_async_stream_extractors_skip_empty_chunks() -> None:
tools_handler = handlers.VertexAIToolsHandler()
json_handler = handlers.VertexAIJSONHandler()
tool_stream = [object(), _response(_part(args={"city": "Paris"}))]
json_stream = [object(), _response(_part(text='{"city":"Paris"}'))]
tool_chunks = [
chunk
async for chunk in tools_handler.extract_streaming_json_async(
async_items(tool_stream)
)
]
json_chunks = [
chunk
async for chunk in json_handler.extract_streaming_json_async(
async_items(json_stream)
)
]
assert tool_chunks == ['{"city": "Paris"}']
assert json_chunks == ['{"city":"Paris"}']
@pytest.mark.asyncio
async def test_vertexai_streaming_parsers_return_sync_and_async_iterable_items() -> (
None
):
tools_handler = handlers.VertexAIToolsHandler()
json_handler = handlers.VertexAIJSONHandler()
weather_list = IterableModel(Weather)
tool_stream = [
_response(_part(args={"tasks": [{"city": "Paris"}, {"city": "London"}]}))
]
json_stream = [_response(_part(text='{"tasks":[{"city":"Tokyo"}]}'))]
sync_tools = tools_handler.parse_response(
tool_stream,
weather_list,
stream=True,
validation_context={"request_id": "sync"},
strict=True,
)
async_tools = tools_handler.parse_response(
async_items(tool_stream),
weather_list,
stream=True,
validation_context={"request_id": "async-tool"},
strict=True,
)
async_json = json_handler.parse_response(
async_items(json_stream),
weather_list,
stream=True,
validation_context={"request_id": "async-json"},
strict=True,
)
default_json = json_handler.parse_response(json_stream, weather_list, stream=True)
assert [item.city for item in sync_tools] == ["Paris", "London"]
assert [item.city async for item in async_tools] == ["Paris", "London"]
assert [item.city async for item in async_json] == ["Tokyo"]
assert [item.city for item in default_json] == ["Tokyo"]
@pytest.mark.asyncio
async def test_vertexai_streaming_parser_materializes_partial_and_custom_models() -> (
None
):
handler = handlers.VertexAIJSONHandler()
class StreamingWeather(BaseModel):
city: str
@classmethod
def from_streaming_response(
cls,
completion: Iterable[Any],
stream_extractor: Any,
**kwargs: Any,
) -> Iterable[StreamingWeather]:
assert kwargs == {"context": {"request_id": "custom"}, "strict": False}
payload = "".join(stream_extractor(completion))
yield cls.model_validate_json(payload, **kwargs)
partial = handler.parse_response(
[_response(_part(text='{"city":"Par')), _response(_part(text='is"}'))],
Partial[Weather],
stream=True,
validation_context={"request_id": "partial"},
strict=True,
)
async_partial = [
item
async for item in handler.parse_response(
async_items(
[_response(_part(text='{"city":"Lon')), _response(_part(text='don"}'))]
),
Partial[Weather],
stream=True,
validation_context={"request_id": "async-partial"},
strict=True,
)
]
custom = handler._parse_streaming(
StreamingWeather,
[_response(_part(text='{"city":"Oslo"}'))],
validation_context={"request_id": "custom"},
strict=False,
)
assert isinstance(partial, list)
assert [item.city for item in partial] == ["Par", "Paris"]
assert async_partial[-1].model_dump() == {"city": "London"}
assert isinstance(custom, list)
assert [item.city for item in custom] == ["Oslo"]
def test_vertexai_parallel_parsers_validate_known_calls_and_skip_empty_candidates() -> (
None
):
model = VertexAIParallelModel(Iterable[Union[Weather, Score]])
response = SimpleNamespace(
candidates=[
SimpleNamespace(content=None),
SimpleNamespace(content=SimpleNamespace(parts=[])),
SimpleNamespace(
content=SimpleNamespace(
parts=[
SimpleNamespace(text="No function call was requested."),
SimpleNamespace(function_call=None),
_part(name="Weather", args={"city": "Paris"}),
_part(name="Ignored", args={"other": True}),
_part(name="Score", args={"value": 9}),
]
)
),
]
)
tools_handler = handlers.VertexAIToolsHandler()
parallel_handler = handlers.VertexAIParallelToolsHandler()
parsed_by_tools = list(
tools_handler.parse_response(
response,
model,
validation_context={"request_id": "tools"},
strict=True,
)
)
parsed_parallel = list(
parallel_handler.parse_response(
response,
model,
validation_context={"request_id": "parallel"},
strict=True,
)
)
single_response = _response(_part(name="Weather", args={"city": "Paris"}))
parsed_single = parallel_handler.parse_response(single_response, Weather)
assert parsed_by_tools == [Weather(city="Paris"), Score(value=9)]
assert parsed_parallel == [Weather(city="Paris"), Score(value=9)]
assert parsed_single == Weather(city="Paris")
assert parsed_single._raw_response is single_response
def test_vertexai_message_templating_preserves_role_and_renders_each_part() -> None:
message = gm.Content(
role="user",
parts=[
gm.Part.from_text("Weather in {{ city }}?"),
gm.Part.from_text("Use {{ unit }}."),
],
)
calls: list[tuple[str, dict[str, Any]]] = []
def apply_template(value: str, context: dict[str, Any]) -> str:
calls.append((value, context))
return value.replace("{{ city }}", context["city"]).replace(
"{{ unit }}", context["unit"]
)
rendered = templating.process_message(
message, {"city": "Paris", "unit": "Celsius"}, apply_template
)
assert rendered.role == "user"
assert [part.text for part in rendered.parts] == [
"Weather in Paris?",
"Use Celsius.",
]
assert calls == [
("Weather in {{ city }}?", {"city": "Paris", "unit": "Celsius"}),
("Use {{ unit }}.", {"city": "Paris", "unit": "Celsius"}),
]