diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 82f5ad977a..75e8a660b4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.13.0" + ".": "3.14.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 302f8e04cd..9b07bcc83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [3.14.0](https://github.com/openai/openai-python/compare/v3.13.0...v3.14.0) (2026-09-14) + + +### Features + +* **streaming:** normalize errors raised while reading streams ([#3827](https://github.com/openai/openai-python/issues/3827)) ([d7c41ef](https://github.com/openai/openai-python/commit/d7c41efee1b0802b79f3f88a678ef2052b06e9ce)) + + +### Bug Fixes + +* bound vector store file polling ([#3401](https://github.com/openai/openai-python/issues/3401)) ([ae41bc4](https://github.com/openai/openai-python/commit/ae41bc46cdd53d17e948ac8a73e16bdbf34123a1)) +* **example:** refresh realtime push_to_talk_app session types ([#2926](https://github.com/openai/openai-python/issues/2926)) ([0b7ad38](https://github.com/openai/openai-python/commit/0b7ad38292ef862f1dd07f71543b2f60eade8999)) +* **files:** normalize PathLike upload tuples ([#3475](https://github.com/openai/openai-python/issues/3475)) ([90ac74c](https://github.com/openai/openai-python/commit/90ac74ccfe7a966787d1f204755d4cd4cc4d406a)) +* include completion in content filter errors ([#3094](https://github.com/openai/openai-python/issues/3094)) ([e57a1b1](https://github.com/openai/openai-python/commit/e57a1b19fbcc32306a691e63334fd2dc4b1a804c)) +* **logging:** support standard OPENAI_LOG levels ([#3734](https://github.com/openai/openai-python/issues/3734)) ([44000e0](https://github.com/openai/openai-python/commit/44000e0fc5e11692663045d6183568ff3ec32736)) +* normalize API error codes to strings ([#3532](https://github.com/openai/openai-python/issues/3532)) ([233d955](https://github.com/openai/openai-python/commit/233d9557be3e7ee186b5ac3f1ab634a257134ac1)), closes [#3531](https://github.com/openai/openai-python/issues/3531) +* preserve response stream indexes after empty items ([#3126](https://github.com/openai/openai-python/issues/3126)) ([8f84fbd](https://github.com/openai/openai-python/commit/8f84fbd688bdd8a9cde17c09f79b34d5d5580f0f)) +* **responses:** handle null text in output_text ([#3019](https://github.com/openai/openai-python/issues/3019)) ([df6b72c](https://github.com/openai/openai-python/commit/df6b72ccc9122a83b8dd21971dcf4a508c04b83a)) + + +### Documentation + +* use the vision guide image in README example ([#3849](https://github.com/openai/openai-python/issues/3849)) ([1ed8902](https://github.com/openai/openai-python/commit/1ed8902e5629c1aae0c26eadceff02bd5cebb0e1)) + ## [3.13.0](https://github.com/openai/openai-python/compare/v3.12.0...v3.13.0) (2026-09-10) diff --git a/README.md b/README.md index 1b3824da05..07656a0a12 100644 --- a/README.md +++ b/README.md @@ -810,7 +810,9 @@ You can enable logging by setting the environment variable `OPENAI_LOG` to `info $ export OPENAI_LOG=info ``` -Or to `debug` for more verbose logging. +Or to `debug` for more verbose logging. Set it to `warning`, `error`, or `critical` +to show only messages at that level or higher. `OPENAI_LOG` configures the `openai` +logger; configure HTTP transport loggers separately using Python logging. ### How to tell whether `None` means `null` or missing diff --git a/examples/realtime/push_to_talk_app.py b/examples/realtime/push_to_talk_app.py index 28e8126b89..f0ff88a74b 100755 --- a/examples/realtime/push_to_talk_app.py +++ b/examples/realtime/push_to_talk_app.py @@ -41,8 +41,8 @@ from textual.containers import Container from openai import AsyncOpenAI -from openai.types.realtime.session import Session from openai.resources.realtime.realtime import AsyncRealtimeConnection +from openai.types.realtime.session_created_event import Session class SessionDisplay(Static): @@ -179,8 +179,10 @@ async def handle_realtime_connection(self) -> None: if event.type == "session.created": self.session = event.session session_display = self.query_one(SessionDisplay) - assert event.session.id is not None - session_display.session_id = event.session.id + # The session ID is returned as an extra field on the session configuration. + session_id = getattr(event.session, "id", None) + assert isinstance(session_id, str) + session_display.session_id = session_id continue if event.type == "session.updated": @@ -275,7 +277,12 @@ async def on_key(self, event: events.Key) -> None: self.should_send_audio.clear() status_indicator.is_recording = False - if self.session and self.session.turn_detection is None: + if ( + self.session + and self.session.audio + and self.session.audio.input + and self.session.audio.input.turn_detection is None + ): # The default in the API is that the model will automatically detect when the user has # stopped talking and then start responding itself. # diff --git a/helpers.md b/helpers.md index 89ff4498cf..b6dd1d4e8a 100644 --- a/helpers.md +++ b/helpers.md @@ -510,9 +510,9 @@ The polling methods are: client.beta.threads.create_and_run_poll(...) client.beta.threads.runs.create_and_poll(...) client.beta.threads.runs.submit_tool_outputs_and_poll(...) -client.beta.vector_stores.files.upload_and_poll(...) -client.beta.vector_stores.files.create_and_poll(...) -client.beta.vector_stores.file_batches.create_and_poll(...) -client.beta.vector_stores.file_batches.upload_and_poll(...) +client.vector_stores.files.upload_and_poll(...) +client.vector_stores.files.create_and_poll(...) +client.vector_stores.file_batches.create_and_poll(...) +client.vector_stores.file_batches.upload_and_poll(...) client.videos.create_and_poll(...) ``` diff --git a/pyproject.toml b/pyproject.toml index 3e4b6f98cd..a7e4f5c931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "3.13.0" +version = "3.14.0" description = "The official Python library for the openai API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 7a30e4a336..bdaa103ee8 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -69,7 +69,8 @@ def __init__(self, message: str, request: httpx2.Request, *, body: object | None self.body = body if is_dict(body): - self.code = cast(Any, construct_type(type_=Optional[str], value=body.get("code"))) + code = body.get("code") + self.code = str(code) if code is not None else None self.param = cast(Any, construct_type(type_=Optional[str], value=body.get("param"))) self.type = cast(Any, construct_type(type_=str, value=body.get("type"))) else: @@ -179,10 +180,17 @@ def __init__(self, *, completion: ChatCompletion) -> None: class ContentFilterFinishReasonError(OpenAIError): - def __init__(self) -> None: + completion: ChatCompletion | None + """The completion that caused this error, if provided. + + When streaming, this is the accumulated completion and may not include usage. + """ + + def __init__(self, *, completion: ChatCompletion | None = None) -> None: super().__init__( - f"Could not parse response content as the request was rejected by the content filter", + "Could not parse response content as the request was rejected by the content filter", ) + self.completion = completion class InvalidWebhookSignatureError(ValueError): diff --git a/src/openai/_files.py b/src/openai/_files.py index 1a2cc77478..76f71238ff 100644 --- a/src/openai/_files.py +++ b/src/openai/_files.py @@ -27,13 +27,11 @@ def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: def is_file_content(obj: object) -> TypeGuard[FileContent]: - return ( - isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) - ) + return isinstance(obj, bytes) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) def assert_is_file_content(obj: object, *, key: str | None = None) -> None: - if not is_file_content(obj): + if not is_file_content(obj) and not is_tuple_t(obj): prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" raise RuntimeError( f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/openai/openai-python/tree/main#file-uploads" diff --git a/src/openai/_response.py b/src/openai/_response.py index 8b819ce812..e18f927a69 100644 --- a/src/openai/_response.py +++ b/src/openai/_response.py @@ -589,7 +589,7 @@ class StreamAlreadyConsumed(OpenAIError): been streamed. This can happen if you use a method like `.iter_lines()` and then attempt - to read th entire response body afterwards, e.g. + to read the entire response body afterwards, e.g. ```py response = await client.post(...) diff --git a/src/openai/_utils/_logs.py b/src/openai/_utils/_logs.py index c2c92e62f4..f41d9df6e6 100644 --- a/src/openai/_utils/_logs.py +++ b/src/openai/_utils/_logs.py @@ -8,6 +8,13 @@ SENSITIVE_HEADERS = {"api-key", "authorization", "x-amz-security-token"} +_LOG_LEVELS = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, +} def _basic_config() -> None: @@ -29,12 +36,15 @@ def setup_logging() -> None: # Transport loggers may include complete URLs. Leave their configuration to # the application instead of enabling them with the SDK's logging switch. env = os.environ.get("OPENAI_LOG") - if env == "debug": - _basic_config() - logger.setLevel(logging.DEBUG) - elif env == "info": - _basic_config() - logger.setLevel(logging.INFO) + if env is None: + return + + level = _LOG_LEVELS.get(env) + if level is None: + return + + _basic_config() + logger.setLevel(level) class SensitiveHeadersFilter(logging.Filter): diff --git a/src/openai/_version.py b/src/openai/_version.py index d997c6cc9a..6d92ce7071 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,2 +1,2 @@ __title__ = "openai" -__version__ = "3.13.0" # x-release-please-version +__version__ = "3.14.0" # x-release-please-version diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 7a1bded1de..ebea435e93 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -100,7 +100,7 @@ def parse_chat_completion( raise LengthFinishReasonError(completion=chat_completion) if choice.finish_reason == "content_filter": - raise ContentFilterFinishReasonError() + raise ContentFilterFinishReasonError(completion=chat_completion) message = choice.message diff --git a/src/openai/lib/streaming/chat/_completions.py b/src/openai/lib/streaming/chat/_completions.py index cf3ecfe213..28c7d02a9f 100644 --- a/src/openai/lib/streaming/chat/_completions.py +++ b/src/openai/lib/streaming/chat/_completions.py @@ -379,7 +379,7 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS choice_snapshot.message, # we don't want to serialise / deserialise our custom properties # as they won't appear in the delta and we don't want to have to - # continuosly reparse the content + # continuously reparse the content exclude=cast( # cast required as mypy isn't smart enough to infer `True` here to `Literal[True]` IncEx, @@ -431,7 +431,7 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS raise LengthFinishReasonError(completion=completion_snapshot) if choice.finish_reason == "content_filter": - raise ContentFilterFinishReasonError() + raise ContentFilterFinishReasonError(completion=completion_snapshot) if ( choice_snapshot.message.content diff --git a/src/openai/types/responses/response.py b/src/openai/types/responses/response.py index 92c02ce69f..751d6d6b76 100644 --- a/src/openai/types/responses/response.py +++ b/src/openai/types/responses/response.py @@ -554,7 +554,8 @@ def output_text(self) -> str: for output in self.output: if output.type == "message": for content in output.content: - if content.type == "output_text": + # Tolerate null text from compatible providers without changing the public string type. + if content.type == "output_text" and content.text is not None: # pyright: ignore[reportUnnecessaryComparison] texts.append(content.text) return "".join(texts) diff --git a/tests/lib/chat/test_content_filter.py b/tests/lib/chat/test_content_filter.py new file mode 100644 index 0000000000..06bc2ffcd0 --- /dev/null +++ b/tests/lib/chat/test_content_filter.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json + +import httpx2 +import pytest +from pydantic import BaseModel + +from openai import OpenAI, AsyncOpenAI, ContentFilterFinishReasonError +from tests.respx2 import MockRouter + +from ...conftest import base_url + + +class Output(BaseModel): + value: str + + +ERROR_MESSAGE = "Could not parse response content as the request was rejected by the content filter" + + +def test_content_filter_error_without_completion() -> None: + error = ContentFilterFinishReasonError() + assert error.completion is None + assert str(error) == ERROR_MESSAGE + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +@pytest.mark.respx2(base_url=base_url) +async def test_parse_content_filter_completion( + sync: bool, client: OpenAI, async_client: AsyncOpenAI, respx2_mock: MockRouter +) -> None: + respx2_mock.post("/chat/completions").mock( + return_value=httpx2.Response( + 200, + json={ + "id": "chatcmpl-example", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-2024-08-06", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": None}, "finish_reason": "content_filter"} + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 0, "total_tokens": 5}, + }, + ) + ) + + with pytest.raises(ContentFilterFinishReasonError) as exc_info: + if sync: + client.chat.completions.parse(model="gpt-4o-2024-08-06", messages=[], response_format=Output) + else: + await async_client.chat.completions.parse(model="gpt-4o-2024-08-06", messages=[], response_format=Output) + + error = exc_info.value + assert error.completion is not None + assert error.completion.id == "chatcmpl-example" + assert error.completion.choices[0].finish_reason == "content_filter" + assert error.completion.usage is not None + assert error.completion.usage.total_tokens == 5 + assert str(error) == ERROR_MESSAGE + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +@pytest.mark.parametrize("partial_content", [None, '{"value": "example'], ids=["immediate", "after_content"]) +@pytest.mark.respx2(base_url=base_url) +async def test_stream_content_filter_completion( + sync: bool, + partial_content: str | None, + client: OpenAI, + async_client: AsyncOpenAI, + respx2_mock: MockRouter, +) -> None: + choices: list[dict[str, object]] = [] + if partial_content is not None: + choices.append({"index": 0, "delta": {"role": "assistant", "content": partial_content}, "finish_reason": None}) + choices.append({"index": 0, "delta": {"role": "assistant"}, "finish_reason": "content_filter"}) + events = [ + { + "id": "chatcmpl-example", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-2024-08-06", + "choices": [choice], + } + for choice in choices + ] + response = httpx2.Response( + 200, + headers={"content-type": "text/event-stream"}, + content="".join(f"data: {json.dumps(event)}\n\n" for event in events) + "data: [DONE]\n\n", + ) + respx2_mock.post("/chat/completions").mock(return_value=response) + + with pytest.raises(ContentFilterFinishReasonError) as exc_info: + if sync: + with client.chat.completions.stream( + model="gpt-4o-2024-08-06", messages=[], response_format=Output + ) as stream: + stream.get_final_completion() + else: + async with async_client.chat.completions.stream( + model="gpt-4o-2024-08-06", messages=[], response_format=Output + ) as async_stream: + await async_stream.get_final_completion() + + error = exc_info.value + assert error.completion is not None + assert error.completion.id == "chatcmpl-example" + assert error.completion.choices[0].finish_reason == "content_filter" + assert error.completion.choices[0].message.content == partial_content + assert error.completion.usage is None + assert str(error) == ERROR_MESSAGE diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 5ba5a1e2b3..1c0a1072bb 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -46,6 +46,42 @@ def test_output_text(client: OpenAI, respx2_mock: MockRouter) -> None: ) +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.parametrize("client,async_client", [(False, False)], indirect=True) # default loose validation +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +@pytest.mark.parametrize( + "text_fields,expected", + [ + pytest.param([{"text": "hello"}, {"text": None}, {"text": " world"}], "hello world", id="mixed-null"), + pytest.param([{"text": None}, {"text": None}], "", id="all-null"), + pytest.param([{}, {"text": "hello"}], "hello", id="missing"), + pytest.param([{"text": "hello"}, {"text": " world"}], "hello world", id="strings"), + pytest.param([{"text": ""}], "", id="empty-string"), + pytest.param([], "", id="empty-content"), + ], +) +async def test_output_text_with_nullable_content( + client: OpenAI, + async_client: AsyncOpenAI, + respx2_mock: MockRouter, + sync: bool, + text_fields: list[dict[str, str | None]], + expected: str, +) -> None: + content: list[dict[str, object]] = [{"type": "output_text", "annotations": [], **fields} for fields in text_fields] + output: list[dict[str, object]] = [{"type": "message", "role": "assistant", "content": content}] + respx2_mock.post("/responses").respond(json={"output": output}) + + if sync: + response = client.responses.create(model="gpt-4o-mini", input="Say hello") + else: + response = await async_client.responses.create(model="gpt-4o-mini", input="Say hello") + + assert response.to_dict()["output"] == output + assert response.output_text == expected + assert response.to_dict()["output"] == output + + @pytest.mark.parametrize( "item", [ diff --git a/tests/test_client.py b/tests/test_client.py index 7fa7ee31bd..3281add6d2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -123,6 +123,40 @@ def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int: class TestOpenAI: + @pytest.mark.parametrize( + "code_fields,expected_code", + [ + ({"code": 404}, "404"), + ({"code": 0}, "0"), + ({"code": "invalid_request"}, "invalid_request"), + ({"code": ""}, ""), + ({"code": None}, None), + ({}, None), + ], + ) + @pytest.mark.respx2(base_url=base_url) + def test_api_error_code_is_string( + self, + code_fields: dict[str, object], + expected_code: str | None, + respx2_mock: MockRouter, + client: OpenAI, + ) -> None: + body = {"message": "Example error", "type": "invalid_request_error", "param": "model", **code_fields} + response = httpx2.Response(400, json={"error": body}) + respx2_mock.get("/foo").mock(return_value=response) + + with pytest.raises(APIStatusError) as exc_info: + client.get("/foo", cast_to=httpx2.Response) + + error = exc_info.value + assert error.code == expected_code + assert error.body == body + assert error.response.json() == {"error": body} + assert error.status_code == 400 + assert error.type == "invalid_request_error" + assert error.param == "model" + @pytest.mark.respx2(base_url=base_url) def test_raw_response(self, respx2_mock: MockRouter, client: OpenAI) -> None: respx2_mock.post("/foo").mock(return_value=httpx2.Response(200, json={"foo": "bar"})) @@ -1454,6 +1488,40 @@ def test_copy_auth(self) -> None: class TestAsyncOpenAI: + @pytest.mark.parametrize( + "code_fields,expected_code", + [ + ({"code": 404}, "404"), + ({"code": 0}, "0"), + ({"code": "invalid_request"}, "invalid_request"), + ({"code": ""}, ""), + ({"code": None}, None), + ({}, None), + ], + ) + @pytest.mark.respx2(base_url=base_url) + async def test_api_error_code_is_string( + self, + code_fields: dict[str, object], + expected_code: str | None, + respx2_mock: MockRouter, + async_client: AsyncOpenAI, + ) -> None: + body = {"message": "Example error", "type": "invalid_request_error", "param": "model", **code_fields} + response = httpx2.Response(400, json={"error": body}) + respx2_mock.get("/foo").mock(return_value=response) + + with pytest.raises(APIStatusError) as exc_info: + await async_client.get("/foo", cast_to=httpx2.Response) + + error = exc_info.value + assert error.code == expected_code + assert error.body == body + assert error.response.json() == {"error": body} + assert error.status_code == 400 + assert error.type == "invalid_request_error" + assert error.param == "model" + @pytest.mark.respx2(base_url=base_url) async def test_raw_response(self, respx2_mock: MockRouter, async_client: AsyncOpenAI) -> None: respx2_mock.post("/foo").mock(return_value=httpx2.Response(200, json={"foo": "bar"})) diff --git a/tests/test_files.py b/tests/test_files.py index ee7e9bcc74..a94ddce0ef 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -19,6 +19,21 @@ def test_tuple_input() -> None: assert result == [("file", ("README.md", readme_path.read_bytes()))] +def test_file_tuple_with_pathlike_content() -> None: + result = to_httpx_files({"file": ("custom-name.md", readme_path)}) + assert result == {"file": ("custom-name.md", readme_path.read_bytes())} + + +def test_file_tuple_with_pathlike_content_and_metadata() -> None: + result = to_httpx_files({"file": ("custom-name.md", readme_path, "text/markdown")}) + assert result == {"file": ("custom-name.md", readme_path.read_bytes(), "text/markdown")} + + +def test_file_tuple_with_pathlike_content_and_headers() -> None: + result = to_httpx_files({"file": ("custom-name.md", readme_path, "text/markdown", {"X-Test": "1"})}) + assert result == {"file": ("custom-name.md", readme_path.read_bytes(), "text/markdown", {"X-Test": "1"})} + + @pytest.mark.asyncio async def test_async_pathlib_includes_file_name() -> None: result = await async_to_httpx_files({"file": readme_path}) @@ -37,6 +52,26 @@ async def test_async_tuple_input() -> None: assert result == [("file", ("README.md", readme_path.read_bytes()))] +@pytest.mark.asyncio +async def test_async_file_tuple_with_pathlike_content() -> None: + result = await async_to_httpx_files({"file": ("custom-name.md", readme_path)}) + assert result == {"file": ("custom-name.md", readme_path.read_bytes())} + + +@pytest.mark.asyncio +async def test_async_file_tuple_with_pathlike_content_and_metadata() -> None: + result = await async_to_httpx_files({"file": ("custom-name.md", readme_path, "text/markdown")}) + assert result == {"file": ("custom-name.md", readme_path.read_bytes(), "text/markdown")} + + +@pytest.mark.asyncio +async def test_async_file_tuple_with_pathlike_content_and_headers() -> None: + result = await async_to_httpx_files( + {"file": ("custom-name.md", anyio.Path(readme_path), "text/markdown", {"X-Test": "1"})} + ) + assert result == {"file": ("custom-name.md", readme_path.read_bytes(), "text/markdown", {"X-Test": "1"})} + + def test_string_not_allowed() -> None: with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): to_httpx_files( @@ -111,6 +146,17 @@ def test_extract_files_does_not_mutate_original_top_level(self) -> None: assert original == {"file": file_bytes, "other": "value"} assert copied == {"other": "value"} + def test_extract_files_accepts_file_tuple(self) -> None: + file_tuple = ("custom-name.jsonl", b"contents", "application/jsonl") + original = {"file": file_tuple, "purpose": "batch"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_tuple)] + assert original == {"file": file_tuple, "purpose": "batch"} + assert copied == {"purpose": "batch"} + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: file1 = b"f1" file2 = b"f2" diff --git a/tests/test_log_levels.py b/tests/test_log_levels.py new file mode 100644 index 0000000000..e36f7005e1 --- /dev/null +++ b/tests/test_log_levels.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import logging + +import pytest + +from openai._utils._logs import setup_logging + + +@pytest.mark.parametrize( + ("setting", "expected_level"), + [ + ("debug", logging.DEBUG), + ("info", logging.INFO), + ("warning", logging.WARNING), + ("error", logging.ERROR), + ("critical", logging.CRITICAL), + ], +) +def test_openai_log_sets_standard_log_level( + setting: str, + expected_level: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + logger = logging.getLogger("openai") + original_level = logger.level + + try: + monkeypatch.setenv("OPENAI_LOG", setting) + setup_logging() + assert logger.level == expected_level + finally: + logger.setLevel(original_level) diff --git a/uv.lock b/uv.lock index 900b929ddc..1fc72a186a 100644 --- a/uv.lock +++ b/uv.lock @@ -1524,7 +1524,7 @@ wheels = [ [[package]] name = "openai" -version = "3.13.0" # x-release-please-version +version = "3.14.0" # x-release-please-version source = { editable = "." } dependencies = [ { name = "anyio" },