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

461 lines
14 KiB
Python

"""Coverage for the public batch parsing, request, and result APIs."""
from __future__ import annotations
import io
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
from pydantic import BaseModel
from instructor.batch import (
BatchError,
BatchJob,
BatchJobInfo,
BatchRequest,
BatchStatus,
BatchSuccess,
extract_results,
filter_errors,
filter_successful,
get_results_by_custom_id,
)
pytestmark = pytest.mark.unit
class Person(BaseModel):
name: str
age: int
class PersonGroup(BaseModel):
people: list[Person]
class TypelessResponse(BaseModel):
value: str
@classmethod
def model_json_schema(cls, *_args, **_kwargs) -> dict:
return {"properties": {"value": {"type": "string"}}, "required": ["value"]}
class RestrictedResponse(BaseModel):
value: str
@classmethod
def model_json_schema(cls, *_args, **_kwargs) -> dict:
return {
"type": "object",
"additionalProperties": True,
"properties": {"value": {"type": "string"}, "forbidden": False},
"required": ["value"],
}
def test_legacy_batch_job_parses_provider_results_and_preserves_errors(
tmp_path: Path,
) -> None:
openai_json = {
"custom_id": "openai-json",
"response": {
"body": {
"choices": [
{"message": {"content": json.dumps({"name": "Ada", "age": 36})}}
]
}
},
}
openai_tool = {
"custom_id": "openai-tool",
"response": {
"body": {
"choices": [
{
"message": {
"tool_calls": [
{
"function": {
"arguments": json.dumps(
{"name": "Grace", "age": 42}
)
}
}
]
}
}
]
}
},
}
anthropic_tool = {
"custom_id": "anthropic-tool",
"result": {
"message": {
"content": [
{"type": "text", "text": "Using the extraction tool."},
{"type": "tool_use", "input": {"name": "Katherine", "age": 51}},
]
}
},
}
anthropic_text = {
"custom_id": "anthropic-text",
"result": {
"message": {
"content": [
{
"type": "text",
"text": json.dumps({"name": "Margaret", "age": 28}),
}
]
}
},
}
invalid_model = {
"custom_id": "invalid-model",
"response": {
"body": {
"choices": [{"message": {"content": json.dumps({"name": "No age"})}}]
}
},
}
malformed_content = {
"custom_id": "malformed-content",
"response": {"body": {"choices": [{"message": {"content": "not json"}}]}},
}
unsupported_shape = {"custom_id": "unsupported", "response": {"body": {}}}
content = "\n".join(
[
json.dumps(openai_json),
" ",
json.dumps(openai_tool),
json.dumps(anthropic_tool),
json.dumps(anthropic_text),
json.dumps(invalid_model),
json.dumps(malformed_content),
json.dumps(unsupported_shape),
"{not-valid-json",
]
)
batch_file = tmp_path / "batch-results.jsonl"
batch_file.write_text(content)
results, errors = BatchJob.parse_from_file(str(batch_file), Person)
assert results == [
Person(name="Ada", age=36),
Person(name="Grace", age=42),
Person(name="Katherine", age=51),
Person(name="Margaret", age=28),
]
assert errors[:3] == [invalid_model, malformed_content, unsupported_shape]
assert errors[3] == {"error": "Failed to parse JSON", "raw_line": "{not-valid-json"}
@pytest.mark.parametrize(
("payload", "expected"),
[
(
{
"response": {
"body": {
"choices": [
{
"message": {
"content": {"ignored": True},
"tool_calls": [
{
"function": {
"arguments": '{"name":"Ada","age":36}'
}
}
],
}
}
]
}
}
},
{"name": "Ada", "age": 36},
),
(
{"response": {"body": {"choices": [{"message": {"role": "assistant"}}]}}},
None,
),
({"result": {"message": {"content": []}}}, None),
(
{"result": {"message": {"content": [{"type": "image", "source": {}}]}}},
None,
),
(
{
"result": {
"message": {
"content": [
{"type": "image", "source": {}},
{"type": "text", "text": '{"name":"Lin","age":28}'},
]
}
}
},
{"name": "Lin", "age": 28},
),
],
)
def test_legacy_batch_job_handles_empty_unknown_and_mixed_content_blocks(
payload: dict, expected: dict | None
) -> None:
assert BatchJob._extract_structured_data(payload) == expected
def test_openai_batch_job_info_normalizes_status_timestamps_counts_and_error() -> None:
payload = {
"id": "batch-openai",
"status": "failed",
"created_at": 1_700_000_000,
"in_progress_at": 1_700_000_010,
"completed_at": 1_700_000_020,
"failed_at": 1_700_000_021,
"cancelled_at": 1_700_000_022,
"expired_at": 1_700_000_023,
"expires_at": 1_700_000_024,
"request_counts": {"total": 12, "completed": 8, "failed": 4},
"input_file_id": "file-input",
"output_file_id": "file-output",
"error_file_id": "file-error",
"errors": {
"type": "invalid_request_error",
"message": "bad input",
"code": "bad",
},
"metadata": {"tenant": "example"},
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
}
result = BatchJobInfo.from_openai(payload)
assert result.id == "batch-openai"
assert result.provider == "openai"
assert result.status is BatchStatus.FAILED
assert result.raw_status == "failed"
assert result.timestamps.created_at == datetime.fromtimestamp(
1_700_000_000, timezone.utc
)
assert result.timestamps.started_at == datetime.fromtimestamp(
1_700_000_010, timezone.utc
)
assert result.timestamps.completed_at == datetime.fromtimestamp(
1_700_000_020, timezone.utc
)
assert result.timestamps.failed_at == datetime.fromtimestamp(
1_700_000_021, timezone.utc
)
assert result.timestamps.cancelled_at == datetime.fromtimestamp(
1_700_000_022, timezone.utc
)
assert result.timestamps.expired_at == datetime.fromtimestamp(
1_700_000_023, timezone.utc
)
assert result.timestamps.expires_at == datetime.fromtimestamp(
1_700_000_024, timezone.utc
)
assert result.request_counts.model_dump() == {
"total": 12,
"completed": 8,
"failed": 4,
"processing": None,
"succeeded": None,
"errored": None,
"cancelled": None,
"expired": None,
}
assert result.files.model_dump() == {
"input_file_id": "file-input",
"output_file_id": "file-output",
"error_file_id": "file-error",
"results_url": None,
}
assert result.error is not None
assert result.error.model_dump() == {
"error_type": "invalid_request_error",
"error_message": "bad input",
"error_code": "bad",
}
assert result.metadata == {"tenant": "example"}
assert result.raw_data == payload
assert result.endpoint == "/v1/chat/completions"
assert result.completion_window == "24h"
minimal = BatchJobInfo.from_openai({"id": "batch-new", "status": "queued"})
assert minimal.status is BatchStatus.PENDING
assert minimal.timestamps == minimal.timestamps.__class__()
assert minimal.error is None
def test_anthropic_batch_job_info_accepts_timestamp_variants_and_counts() -> None:
created_at = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc)
payload = {
"id": "batch-anthropic",
"processing_status": "ended",
"created_at": created_at,
"cancel_initiated_at": "not-a-timestamp",
"ended_at": 17,
"expires_at": "2025-01-02T12:00:00Z",
"request_counts": {
"processing": 1,
"succeeded": 7,
"errored": 2,
"canceled": 3,
"expired": 4,
},
"results_url": "https://example.test/results/batch-anthropic",
}
result = BatchJobInfo.from_anthropic(payload)
assert result.id == "batch-anthropic"
assert result.provider == "anthropic"
assert result.status is BatchStatus.COMPLETED
assert result.raw_status == "ended"
assert result.timestamps.created_at == created_at
assert result.timestamps.started_at == created_at
assert result.timestamps.cancelled_at is None
assert result.timestamps.completed_at is None
assert result.timestamps.expires_at == datetime(
2025, 1, 2, 12, 0, tzinfo=timezone.utc
)
assert result.request_counts.model_dump() == {
"total": 10,
"completed": None,
"failed": None,
"processing": 1,
"succeeded": 7,
"errored": 2,
"cancelled": 3,
"expired": 4,
}
assert result.files.results_url == "https://example.test/results/batch-anthropic"
assert result.raw_data == payload
minimal = BatchJobInfo.from_anthropic(
{"id": "batch-new", "processing_status": "queued"}
)
assert minimal.status is BatchStatus.PENDING
assert minimal.timestamps.created_at is None
assert minimal.request_counts.total == 0
def test_openai_batch_request_makes_nested_array_and_definition_schemas_strict() -> (
None
):
request = BatchRequest[PersonGroup](
custom_id="group-1",
messages=[{"role": "user", "content": "Extract the group."}],
response_model=PersonGroup,
model="gpt-4o-mini",
)
result = request.to_openai_format()
schema = result["body"]["response_format"]["json_schema"]["schema"]
assert result["custom_id"] == "group-1"
assert result["method"] == "POST"
assert schema["additionalProperties"] is False
assert schema["properties"]["people"]["type"] == "array"
assert schema["$defs"]["Person"]["additionalProperties"] is False
def test_openai_batch_request_preserves_boolean_property_schema() -> None:
request = BatchRequest[RestrictedResponse](
custom_id="restricted-1",
messages=[{"role": "user", "content": "Extract the value."}],
response_model=RestrictedResponse,
model="gpt-4o-mini",
)
schema = request.to_openai_format()["body"]["response_format"]["json_schema"][
"schema"
]
assert schema["additionalProperties"] is False
assert schema["properties"]["forbidden"] is False
def test_anthropic_batch_request_extracts_system_message_and_completes_schema() -> None:
request = BatchRequest[TypelessResponse](
custom_id="anthropic-1",
messages=[
{"role": "system", "content": "Return one value."},
{"role": "user", "content": "Extract the value."},
],
response_model=TypelessResponse,
model="claude-sonnet",
)
result = request.to_anthropic_format()
assert result["custom_id"] == "anthropic-1"
assert result["params"]["system"] == "Return one value."
assert result["params"]["messages"] == [
{"role": "user", "content": "Extract the value."}
]
assert result["params"]["tools"][0]["input_schema"] == {
"type": "object",
"additionalProperties": False,
"properties": {"value": {"type": "string"}},
"required": ["value"],
}
def test_anthropic_batch_request_preserves_explicit_additional_properties() -> None:
request = BatchRequest[RestrictedResponse](
custom_id="anthropic-restricted-1",
messages=[{"role": "user", "content": "Extract the value."}],
response_model=RestrictedResponse,
model="claude-sonnet",
)
schema = request.to_anthropic_format()["params"]["tools"][0]["input_schema"]
assert schema["type"] == "object"
assert schema["additionalProperties"] is True
assert schema["properties"]["forbidden"] is False
def test_batch_request_rejects_an_unsupported_provider() -> None:
request = BatchRequest[Person](
custom_id="unknown-1",
messages=[{"role": "user", "content": "Extract a person."}],
response_model=Person,
model="model",
)
with pytest.raises(ValueError, match="Unsupported provider: unknown"):
request.save_to_file(io.BytesIO(), "unknown")
def test_batch_result_helpers_keep_successes_errors_and_custom_ids() -> None:
ada = BatchSuccess(custom_id="request-1", result=Person(name="Ada", age=36))
failure = BatchError(
custom_id="request-2",
error_type="rate_limit",
error_message="try later",
raw_data={"status": 429},
)
grace = BatchSuccess(custom_id="request-3", result=Person(name="Grace", age=42))
results = [ada, failure, grace]
assert filter_successful(results) == [ada, grace]
assert filter_errors(results) == [failure]
assert extract_results(results) == [ada.result, grace.result]
assert get_results_by_custom_id(results) == {
"request-1": ada,
"request-2": failure,
"request-3": grace,
}