From d593033557c1a3dccea7eeb7ab65b3287acda237 Mon Sep 17 00:00:00 2001 From: jbeckwith-oai <269036200+jbeckwith-oai@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:00:08 +0000 Subject: [PATCH] fix(api): accept canonical batch responses in strict mode Castiron-Internal-PR: https://github.com/openai/openai-python-internal/pull/285 Castiron-Source-SHA: 2e477ef802566a77f8bcbf9e66809ad52c0f82c2 Castiron-Public-Base-SHA: 553703e2d3db379a83e3ead9e40436b7a66f639e --- .castiron.stats.yml | 6 +- src/openai/_base_client.py | 14 +++ tests/test_vector_batch_compat.py | 175 ++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 tests/test_vector_batch_compat.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 8bb464de23..a75b847028 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: 629365c8-c19e-4349-b8d4-1815a9bfa729 +generation_id: baa135fa-765a-4ab9-9ec1-5c9e432437ce openapi_spec_hash: f56829f9ec48b31ed299c5fb497984d7 openapi_transformed_spec_hash: 400d42fb468bbe078e4f0c87c366490b config_hash: db72038ac88cdb94ae1cafef6aada78e -codegen_sha: a1d286ab5c57c5047e0acc339c0a2359d33678f9 +codegen_sha: 17f71f06a352b4e7b5ef168c3fd4182844620954 codegen_hash: e5f930fec1f997ea02bd1746597be5ff2a24c1917743b5bf2d4a2ee617ef8e3b -public_codegen_sha: 5d14ef0360d1e1cfd09ff815f3a8339c8498e604 +public_codegen_sha: 249df1f919236e7aa68b460c504dafdee7c23bb1 diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 883bed6e19..8985106f4f 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -746,6 +746,20 @@ def _process_response_data( return cast(ResponseT, cast_to.build(response=response, data=data)) if self._strict_response_validation: + # Temporary compatibility for the file-batch discriminator typo. Keep + # the published Literal until the next major, but validate real responses. + # Remove when VectorStoreFileBatch accepts the canonical spelling. + if is_dict(data) and data.get("object") == "vector_store.file_batch": + from .types.vector_stores import VectorStoreFileBatch + + if cast_to is VectorStoreFileBatch: + validated = validate_type( + type_=VectorStoreFileBatch, value={**data, "object": "vector_store.files_batch"} + ) + # Restore only this field on the validated instance; do not mutate + # the response or skip validation of any other field. + object.__setattr__(validated, "object", data["object"]) + return cast(ResponseT, validated) return cast(ResponseT, validate_type(type_=cast_to, value=data)) return cast(ResponseT, construct_type(type_=cast_to, value=data)) diff --git a/tests/test_vector_batch_compat.py b/tests/test_vector_batch_compat.py new file mode 100644 index 0000000000..ea1839cfc3 --- /dev/null +++ b/tests/test_vector_batch_compat.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx2 +import pytest +from pydantic import ValidationError + +from openai import OpenAI, AsyncOpenAI, APIResponseValidationError +from openai._compat import parse_obj, model_dump +from openai.types.vector_stores import VectorStoreFileBatch + + +def batch_payload(object_value: Any = "vector_store.file_batch") -> dict[str, Any]: + return { + "id": "vsfb_test", + "object": object_value, + "created_at": 123, + "file_counts": {"cancelled": 0, "completed": 1, "failed": 0, "in_progress": 0, "total": 1}, + "status": "completed", + "vector_store_id": "vs_test", + "future_field": {"preserved": True}, + } + + +def transport(payload: dict[str, Any]) -> httpx2.MockTransport: + return httpx2.MockTransport( + lambda _request: httpx2.Response(200, json=payload, headers={"x-request-id": "req_test"}) + ) + + +def assert_preserved(result: VectorStoreFileBatch, payload: dict[str, Any]) -> None: + assert model_dump(result) == payload + assert json.loads(result.to_json()) == payload + assert set(payload) - {"future_field"} <= result.model_fields_set + assert result._request_id == "req_test" + + +@pytest.mark.parametrize("object_value", ["vector_store.file_batch", "vector_store.files_batch"]) +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("operation", ["create", "retrieve", "cancel", "poll"]) +def test_sync_responses(object_value: str, strict: bool, operation: str) -> None: + payload = batch_payload(object_value) + with OpenAI( + api_key="test-key", + _strict_response_validation=strict, + http_client=httpx2.Client(transport=transport(payload)), + ) as client: + batches = client.vector_stores.file_batches + if operation == "create": + result = batches.create(vector_store_id="vs_test", file_ids=["file_test"]) + elif operation == "cancel": + result = batches.cancel("vsfb_test", vector_store_id="vs_test") + elif operation == "poll": + result = batches.poll("vsfb_test", vector_store_id="vs_test") + else: + result = batches.retrieve("vsfb_test", vector_store_id="vs_test") + assert_preserved(result, payload) + + +@pytest.mark.parametrize("object_value", ["vector_store.file_batch", "vector_store.files_batch"]) +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("operation", ["create", "retrieve", "cancel", "poll"]) +async def test_async_responses(object_value: str, strict: bool, operation: str) -> None: + payload = batch_payload(object_value) + async with AsyncOpenAI( + api_key="test-key", + _strict_response_validation=strict, + http_client=httpx2.AsyncClient(transport=transport(payload)), + ) as client: + batches = client.vector_stores.file_batches + if operation == "create": + result = await batches.create(vector_store_id="vs_test", file_ids=["file_test"]) + elif operation == "cancel": + result = await batches.cancel("vsfb_test", vector_store_id="vs_test") + elif operation == "poll": + result = await batches.poll("vsfb_test", vector_store_id="vs_test") + else: + result = await batches.retrieve("vsfb_test", vector_store_id="vs_test") + assert_preserved(result, payload) + + +@pytest.mark.parametrize( + "change", + [ + {"object": "unknown"}, + {"object": None}, + {"object": 123}, + {"status": "unknown"}, + {"file_counts": {"total": "invalid"}}, + {"created_at": "invalid"}, + ], +) +def test_other_validation_errors_survive(change: dict[str, Any]) -> None: + payload = {**batch_payload(), **change} + with OpenAI( + api_key="test-key", + _strict_response_validation=True, + http_client=httpx2.Client(transport=transport(payload)), + ) as client: + with pytest.raises(APIResponseValidationError) as error: + client.vector_stores.file_batches.retrieve("vsfb_test", vector_store_id="vs_test") + assert error.value.body == payload + + +async def test_async_other_fields_still_validate() -> None: + payload = {**batch_payload(), "status": "unknown"} + async with AsyncOpenAI( + api_key="test-key", + _strict_response_validation=True, + http_client=httpx2.AsyncClient(transport=transport(payload)), + ) as client: + with pytest.raises(APIResponseValidationError) as error: + await client.vector_stores.file_batches.retrieve("vsfb_test", vector_store_id="vs_test") + assert error.value.body == payload + + +def test_missing_discriminator_still_fails() -> None: + payload = batch_payload() + del payload["object"] + with OpenAI( + api_key="test-key", + _strict_response_validation=True, + http_client=httpx2.Client(transport=transport(payload)), + ) as client: + with pytest.raises(APIResponseValidationError): + client.vector_stores.file_batches.retrieve("vsfb_test", vector_store_id="vs_test") + + +def test_validation_copy_does_not_modify_input() -> None: + payload = batch_payload() + with OpenAI( + api_key="test-key", + _strict_response_validation=True, + http_client=httpx2.Client(transport=transport(payload)), + ) as client: + result = client._process_response_data( + data=payload, cast_to=VectorStoreFileBatch, response=httpx2.Response(200) + ) + assert payload == batch_payload() + assert model_dump(result) == payload + assert ( + result.model_fields_set + == parse_obj(VectorStoreFileBatch, batch_payload("vector_store.files_batch")).model_fields_set + ) + + +def test_direct_validation_and_other_models_are_unchanged() -> None: + legacy = batch_payload("vector_store.files_batch") + assert model_dump(parse_obj(VectorStoreFileBatch, legacy)) == legacy + with pytest.raises(ValidationError): + parse_obj(VectorStoreFileBatch, batch_payload()) + + class OtherBatch(VectorStoreFileBatch): + pass + + with OpenAI( + api_key="test-key", + _strict_response_validation=True, + http_client=httpx2.Client(transport=transport(batch_payload())), + ) as client: + with pytest.raises(APIResponseValidationError): + client._process_response_data( + data=batch_payload(), + cast_to=OtherBatch, + response=httpx2.Response(200, request=httpx2.Request("GET", "https://example.test")), + ) + + +def test_unknown_value_remains_permissive_without_strict_validation() -> None: + payload = batch_payload("unknown") + with OpenAI(api_key="test-key", http_client=httpx2.Client(transport=transport(payload))) as client: + result = client.vector_stores.file_batches.retrieve("vsfb_test", vector_store_id="vs_test") + assert_preserved(result, payload)