Files
567-labs--instructor/tests/coverage/test_provider_clients_coverage.py
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

606 lines
20 KiB
Python

from __future__ import annotations
import builtins
import importlib
import json
import runpy
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any
import pytest
import httpx
from openai.types.chat import ChatCompletion
from pydantic import BaseModel
from instructor.v2.core.client import AsyncInstructor, Instructor
from instructor.v2.core.errors import (
ClientError,
IncompleteOutputException,
ModeError,
ResponseParsingError,
)
from instructor.v2.core.mode import Mode
from instructor.v2.core.providers import Provider
from instructor.v2.dsl.iterable import IterableModel
from instructor.v2.providers.writer.handlers import (
WriterJSONSchemaHandler,
WriterMDJSONHandler,
WriterToolsHandler,
handle_writer_tools,
reask_writer_json,
reask_writer_tools,
)
from tests.coverage._openai import chat_completion, tool_call
from tests.coverage.client_cleanup import (
clear_proxy_environment,
close_async_provider_client,
close_provider_client,
ignore_fireworks_pydantic_warning,
)
class User(BaseModel):
name: str
age: int
PROJECT_ROOT = Path(__file__).resolve().parents[2]
MESSAGES = [{"role": "user", "content": "Return Ada, age 37"}]
PROVIDERS = [
pytest.param("cerebras", Provider.CEREBRAS, id="cerebras"),
pytest.param("fireworks", Provider.FIREWORKS, id="fireworks"),
pytest.param("groq", Provider.GROQ, id="groq"),
pytest.param("writer", Provider.WRITER, id="writer"),
]
def tool_response() -> ChatCompletion:
return chat_completion(
tool_calls=[tool_call("User", {"name": "Ada", "age": 37}, "call-user")],
finish_reason="tool_calls",
usage=True,
)
def text_response(content: str) -> ChatCompletion:
return chat_completion(content=content, usage=True)
def run_with_blocked_import(
monkeypatch: pytest.MonkeyPatch, path: Path, blocked_module: str
) -> dict[str, Any]:
real_import = builtins.__import__
def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == blocked_module:
raise ImportError(
f"No module named '{blocked_module}'", name=blocked_module
)
return real_import(name, *args, **kwargs)
with monkeypatch.context() as scoped:
scoped.setattr(builtins, "__import__", guarded_import)
return runpy.run_path(str(path))
@pytest.mark.parametrize(
("provider", "sdk_module", "sync_name", "async_name", "install_hint"),
[
pytest.param(
"cerebras",
"cerebras.cloud.sdk",
"Cerebras",
"AsyncCerebras",
"cerebras-cloud-sdk",
id="cerebras",
),
pytest.param(
"fireworks",
"fireworks.client",
"Fireworks",
"AsyncFireworks",
"fireworks-ai",
id="fireworks",
),
pytest.param("groq", "groq", "groq", "groq", "groq", id="groq"),
pytest.param(
"writer", "writerai", "Writer", "AsyncWriter", "writer-sdk", id="writer"
),
],
)
def test_client_factories_give_clear_error_when_optional_sdk_is_missing(
monkeypatch: pytest.MonkeyPatch,
provider: str,
sdk_module: str,
sync_name: str,
async_name: str,
install_hint: str,
) -> None:
namespace = run_with_blocked_import(
monkeypatch,
PROJECT_ROOT / f"instructor/v2/providers/{provider}/client.py",
sdk_module,
)
assert namespace[sync_name] is None
assert namespace[async_name] is None
with pytest.raises(ClientError, match=f"not installed.*{install_hint}"):
namespace[f"from_{provider}"](object())
@pytest.mark.parametrize(("provider", "_provider_value"), PROVIDERS)
def test_provider_package_stays_importable_when_its_client_import_fails(
monkeypatch: pytest.MonkeyPatch, provider: str, _provider_value: Provider
) -> None:
namespace = run_with_blocked_import(
monkeypatch,
PROJECT_ROOT / f"instructor/v2/providers/{provider}/__init__.py",
f"instructor.v2.providers.{provider}.client",
)
assert namespace[f"from_{provider}"] is None
assert namespace["__all__"] == [f"from_{provider}"]
def load_provider_module(provider: str) -> ModuleType:
with ignore_fireworks_pydantic_warning():
module = importlib.import_module(f"instructor.v2.providers.{provider}.client")
return module
def response_payload(provider: str, model: str) -> dict[str, Any]:
payload = tool_response().model_dump(mode="json", exclude_none=True)
payload.update(
model=model,
system_fingerprint="coverage-fingerprint",
time_info={},
)
if provider == "writer":
payload["choices"][0]["message"]["content"] = ""
return payload
def sync_sdk_client(
monkeypatch: pytest.MonkeyPatch,
provider: str,
http_client: httpx.Client,
) -> tuple[ModuleType, Any]:
clear_proxy_environment(monkeypatch)
module = load_provider_module(provider)
kwargs = {
"api_key": "test-key",
"base_url": f"https://{provider}.invalid"
+ ("/v1" if provider == "fireworks" else ""),
}
if provider == "cerebras":
return module, module.Cerebras(
**kwargs,
http_client=http_client,
warm_tcp_connection=False,
_strict_response_validation=True,
)
if provider == "groq":
return module, module.groq.Groq(
**kwargs, http_client=http_client, _strict_response_validation=True
)
if provider == "writer":
return module, module.Writer(
**kwargs, http_client=http_client, _strict_response_validation=True
)
client = module.Fireworks(**kwargs)
client._client_v1._client.close()
client._client_v1._client = http_client
return module, client
async def async_sdk_client(
monkeypatch: pytest.MonkeyPatch,
provider: str,
http_client: httpx.AsyncClient,
) -> tuple[ModuleType, Any]:
clear_proxy_environment(monkeypatch)
module = load_provider_module(provider)
kwargs = {
"api_key": "test-key",
"base_url": f"https://{provider}.invalid"
+ ("/v1" if provider == "fireworks" else ""),
}
if provider == "cerebras":
return module, module.AsyncCerebras(
**kwargs,
http_client=http_client,
warm_tcp_connection=False,
_strict_response_validation=True,
)
if provider == "groq":
return module, module.groq.AsyncGroq(
**kwargs, http_client=http_client, _strict_response_validation=True
)
if provider == "writer":
return module, module.AsyncWriter(
**kwargs, http_client=http_client, _strict_response_validation=True
)
client = module.AsyncFireworks(**kwargs)
await client._client_v1._async_client.aclose()
client._client_v1._async_client = http_client
return module, client
def assert_tools_request(call: dict[str, Any], provider: Provider, model: str) -> None:
assert call["messages"] == MESSAGES
assert call["model"] == model
assert call["tools"][0]["type"] == "function"
assert call["tools"][0]["function"]["name"] == "User"
if provider is Provider.WRITER:
assert call["tool_choice"] == "auto"
else:
assert call["tool_choice"] == {
"type": "function",
"function": {"name": "User"},
}
@pytest.mark.parametrize(("provider", "provider_value"), PROVIDERS)
def test_sync_provider_factory_patches_realistic_chat_completion(
monkeypatch: pytest.MonkeyPatch, provider: str, provider_value: Provider
) -> None:
seen: list[dict[str, Any]] = []
def handle(request: httpx.Request) -> httpx.Response:
assert request.url.host == f"{provider}.invalid"
assert (
request.url.path
== {
"cerebras": "/v1/chat/completions",
"fireworks": "/v1/chat/completions",
"groq": "/openai/v1/chat/completions",
"writer": "/v1/chat",
}[provider]
)
body = json.loads(request.content)
seen.append(body)
return httpx.Response(200, json=response_payload(provider, body["model"]))
http_client = httpx.Client(transport=httpx.MockTransport(handle), trust_env=False)
module, sdk_client = sync_sdk_client(monkeypatch, provider, http_client)
try:
client = getattr(module, f"from_{provider}")(
sdk_client, mode=Mode.TOOLS, model="sync-model"
)
parsed = client.create(User, MESSAGES, max_retries=0)
assert isinstance(client, Instructor)
assert client.client is sdk_client
assert client.provider is provider_value
assert client.mode is Mode.TOOLS
assert isinstance(parsed, User)
assert parsed.model_dump() == {"name": "Ada", "age": 37}
assert len(seen) == 1
assert_tools_request(seen[0], provider_value, "sync-model")
finally:
close_provider_client(sdk_client)
@pytest.mark.asyncio
@pytest.mark.parametrize(("provider", "provider_value"), PROVIDERS)
async def test_async_provider_factory_patches_realistic_chat_completion(
monkeypatch: pytest.MonkeyPatch, provider: str, provider_value: Provider
) -> None:
seen: list[dict[str, Any]] = []
async def handle(request: httpx.Request) -> httpx.Response:
assert request.url.host == f"{provider}.invalid"
assert (
request.url.path
== {
"cerebras": "/v1/chat/completions",
"fireworks": "/v1/chat/completions",
"groq": "/openai/v1/chat/completions",
"writer": "/v1/chat",
}[provider]
)
body = json.loads(request.content)
seen.append(body)
if body.get("stream"):
chunk = {
"id": "chatcmpl-stream-coverage",
"object": "chat.completion.chunk",
"created": 1,
"model": body["model"],
"choices": [
{
"index": 0,
"finish_reason": "stop",
"delta": {"role": "assistant", "content": "streamed"},
}
],
}
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n",
)
return httpx.Response(200, json=response_payload(provider, body["model"]))
http_client = httpx.AsyncClient(
transport=httpx.MockTransport(handle), trust_env=False
)
module, sdk_client = await async_sdk_client(monkeypatch, provider, http_client)
try:
client = getattr(module, f"from_{provider}")(
sdk_client, mode=Mode.TOOLS, model="async-model"
)
parsed = await client.create(User, MESSAGES, max_retries=0)
assert isinstance(client, AsyncInstructor)
assert client.client is sdk_client
assert client.provider is provider_value
assert client.mode is Mode.TOOLS
assert isinstance(parsed, User)
assert parsed.model_dump() == {"name": "Ada", "age": 37}
assert_tools_request(seen[0], provider_value, "async-model")
if provider == "fireworks":
stream = await client.create(None, MESSAGES, max_retries=0, stream=True)
chunks = [chunk async for chunk in stream]
assert len(chunks) == 1
assert chunks[0].choices[0].delta.content == "streamed"
assert seen[1]["stream"] is True
assert seen[1]["model"] == "async-model"
else:
assert len(seen) == 1
finally:
await close_async_provider_client(sdk_client)
@pytest.mark.parametrize(("provider", "provider_value"), PROVIDERS)
def test_provider_factories_reject_wrong_client_and_unsupported_mode(
monkeypatch: pytest.MonkeyPatch, provider: str, provider_value: Provider
) -> None:
module, sdk_client = sync_sdk_client(
monkeypatch, provider, httpx.Client(trust_env=False)
)
factory = getattr(module, f"from_{provider}")
try:
with pytest.raises(
ClientError, match="Client must be an instance.*Got: object"
):
factory(object(), mode=Mode.TOOLS)
with pytest.raises(ModeError) as exc:
factory(sdk_client, mode=Mode.RESPONSES_TOOLS)
assert exc.value.mode == Mode.RESPONSES_TOOLS.value
assert exc.value.provider == provider_value.value
assert Mode.TOOLS.value in exc.value.valid_modes
finally:
close_provider_client(sdk_client)
def test_writer_reask_recovers_message_shapes_without_openai_model_dump() -> None:
choice_response = SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
role="assistant", content='{"age":"old"}', tool_calls=[]
)
)
]
)
tools_kwargs = reask_writer_tools(
{"messages": list(MESSAGES)}, choice_response, ValueError("age must be an int")
)
text_kwargs = reask_writer_json(
{"messages": list(MESSAGES)},
SimpleNamespace(text='{"name":"Ada"}'),
ValueError("age is required"),
)
content_kwargs = reask_writer_json(
{"messages": list(MESSAGES)},
SimpleNamespace(content='{"age":37}'),
ValueError("name is required"),
)
assert tools_kwargs["messages"][1] == {
"role": "assistant",
"content": '{"age":"old"}',
}
assert "age must be an int" in tools_kwargs["messages"][2]["content"]
assert (
"fill tool call arguments/name correctly"
in tools_kwargs["messages"][2]["content"]
)
assert text_kwargs["messages"][1]["content"] == '{"name":"Ada"}'
assert "age is required" in text_kwargs["messages"][2]["content"]
assert content_kwargs["messages"][1]["content"] == '{"age":37}'
assert "name is required" in content_kwargs["messages"][2]["content"]
def test_writer_tools_helper_and_length_error_keep_writer_contract() -> None:
prepared_model, kwargs = handle_writer_tools(User, {"messages": list(MESSAGES)})
incomplete = tool_response()
incomplete.choices[0].finish_reason = "length"
assert prepared_model is User
assert kwargs["tool_choice"] == "auto"
assert kwargs["tools"][0]["function"]["name"] == "User"
assert set(kwargs["tools"][0]["function"]["parameters"]["required"]) == {
"name",
"age",
}
with pytest.raises(IncompleteOutputException) as exc:
WriterToolsHandler().parse_response(incomplete, User)
assert exc.value.last_completion is incomplete
@pytest.mark.parametrize(
("response", "message"),
[
pytest.param(object(), "No choices in Writer response", id="missing-choices"),
pytest.param(
SimpleNamespace(choices=[]),
"No choices in Writer response",
id="empty-choices",
),
pytest.param(
SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop", message=SimpleNamespace(tool_calls=[])
)
]
),
"No tool calls in Writer response",
id="empty-tool-calls",
),
],
)
def test_writer_tools_rejects_malformed_responses(response: Any, message: str) -> None:
with pytest.raises(ResponseParsingError, match=message) as error:
WriterToolsHandler().parse_response(response, User)
assert error.value.mode == Mode.TOOLS.value
assert error.value.raw_response is response
def test_writer_md_json_handles_multimodal_system_prompt_and_empty_messages() -> None:
handler = WriterMDJSONHandler()
multimodal = {
"messages": [
{"role": "system", "content": [{"type": "text", "text": "Be concise"}]},
{"role": "user", "content": "Return Ada"},
]
}
model, multimodal_kwargs = handler.prepare_request(User, multimodal)
_, empty_kwargs = handler.prepare_request(User, {"messages": []})
assert model is not None
assert issubclass(model, User)
assert multimodal_kwargs["messages"][0]["role"] == "system"
assert multimodal_kwargs["messages"][0]["content"][0]["text"].startswith(
"Be concise\n\n"
)
assert '"age"' in multimodal_kwargs["messages"][0]["content"][0]["text"]
assert multimodal_kwargs["messages"][-1]["role"] == "user"
assert any(
"```json codeblock" in part["text"]
for part in multimodal_kwargs["messages"][-1]["content"]
)
assert empty_kwargs["messages"][0]["role"] == "system"
assert '"name"' in empty_kwargs["messages"][0]["content"]
assert "```json codeblock" in empty_kwargs["messages"][1]["content"]
@pytest.mark.parametrize(
("handler_type", "response"),
[
pytest.param(WriterToolsHandler, tool_response(), id="tools"),
pytest.param(
WriterMDJSONHandler,
text_response('```json\n{"name":"Ada","age":37}\n```'),
id="md-json",
),
pytest.param(
WriterJSONSchemaHandler,
text_response('{"name":"Ada","age":37}'),
id="json-schema",
),
],
)
def test_writer_handler_modes_prepare_parse_and_reask(
handler_type: type[
WriterToolsHandler | WriterMDJSONHandler | WriterJSONSchemaHandler
],
response: ChatCompletion,
) -> None:
handler = handler_type()
empty_request = {"messages": list(MESSAGES)}
assert handler.prepare_request(None, empty_request) == (None, empty_request)
model, kwargs = handler.prepare_request(
User,
{
"messages": [
{"role": "system", "content": "Keep the reply short"},
*MESSAGES,
]
},
)
assert model is not None
parsed = handler.parse_response(response, model, strict=True)
reasked = handler.handle_reask(
{"messages": list(MESSAGES)}, response, ValueError("age must be present")
)
assert issubclass(model, User)
assert isinstance(parsed, User)
assert parsed.model_dump() == {"name": "Ada", "age": 37}
assert reasked["messages"][1]["role"] == "assistant"
assert "age must be present" in reasked["messages"][2]["content"]
if isinstance(handler, WriterToolsHandler):
assert kwargs["tool_choice"] == "auto"
assert kwargs["tools"][0]["function"]["name"] == "User"
elif isinstance(handler, WriterMDJSONHandler):
assert kwargs["messages"][0]["content"].startswith("Keep the reply short\n\n")
assert '"age"' in kwargs["messages"][0]["content"]
else:
assert kwargs["response_format"]["type"] == "json_schema"
assert set(kwargs["response_format"]["json_schema"]["schema"]["required"]) == {
"name",
"age",
}
@pytest.mark.parametrize(
("handler", "delta"),
[
pytest.param(
WriterToolsHandler(),
{
"tool_calls": [
SimpleNamespace(
function=SimpleNamespace(
arguments='{"tasks":[{"name":"Ada","age":37}]}'
)
)
]
},
id="tools",
),
pytest.param(
WriterMDJSONHandler(),
{"content": '```json\n{"tasks":[{"name":"Ada","age":37}]}\n```'},
id="md-json",
),
pytest.param(
WriterJSONSchemaHandler(),
{"content": '{"tasks":[{"name":"Ada","age":37}]}'},
id="json-schema",
),
],
)
def test_writer_handlers_parse_streaming_structured_output(
handler: WriterToolsHandler | WriterMDJSONHandler | WriterJSONSchemaHandler,
delta: dict[str, Any],
) -> None:
iterable_user = IterableModel(User)
prepared_model, kwargs = handler.prepare_request(
iterable_user, {"messages": list(MESSAGES), "stream": True}
)
assert prepared_model is not None
chunk = SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(**delta))])
parsed = handler.parse_response([chunk], prepared_model)
assert kwargs["stream"] is True
assert list(parsed) == [User(name="Ada", age=37)]
assert handler._consume_streaming_flag(prepared_model) is False