From 7afb610d43bcbad17621866b86986ac2434b4164 Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Mon, 24 Aug 2026 23:08:07 +0200 Subject: [PATCH 1/2] perf(lib): stop pulling openai.types.beta into every import openai openai/__init__.py imports openai.lib.streaming, whose _assistants module imported openai.types.beta at module scope. That loaded 318 modules on every import openai, for annotations that from __future__ import annotations already defers and two runtime paths that can import locally. Move the annotation-only imports under TYPE_CHECKING and import RunStep and MessageContent inside the two functions that construct them at runtime. import openai drops from 1234 to 916 modules and from 359ms to 257ms median cold import (1.40x, python 3.13, 25 runs). No public API change: openai.AssistantEventHandler and openai.AsyncAssistantEventHandler stay eagerly exported. Refs #2819 --- src/openai/lib/streaming/_assistants.py | 30 ++++++++++++--------- tests/lib/test_streaming_lazy_types.py | 35 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 tests/lib/test_streaming_lazy_types.py diff --git a/src/openai/lib/streaming/_assistants.py b/src/openai/lib/streaming/_assistants.py index 314961230d..a854fc2889 100644 --- a/src/openai/lib/streaming/_assistants.py +++ b/src/openai/lib/streaming/_assistants.py @@ -10,18 +10,20 @@ from ..._httpx2 import timeout_exceptions from ..._models import construct_type from ..._streaming import Stream, AsyncStream -from ...types.beta import AssistantStreamEvent -from ...types.beta.threads import ( - Run, - Text, - Message, - ImageFile, - TextDelta, - MessageDelta, - MessageContent, - MessageContentDelta, -) -from ...types.beta.threads.runs import RunStep, ToolCall, RunStepDelta, ToolCallDelta + +if TYPE_CHECKING: + from ...types.beta import AssistantStreamEvent + from ...types.beta.threads import ( + Run, + Text, + Message, + ImageFile, + TextDelta, + MessageDelta, + MessageContent, + MessageContentDelta, + ) + from ...types.beta.threads.runs import RunStep, ToolCall, RunStepDelta, ToolCallDelta def _timeout_exceptions() -> tuple[type[Exception], ...]: @@ -903,6 +905,8 @@ def accumulate_run_step( return if event.event == "thread.run.step.delta": + from ...types.beta.threads.runs import RunStep + data = event.data snapshot = run_step_snapshots[data.id] @@ -928,6 +932,8 @@ def accumulate_event( current_message_snapshot: Message | None, ) -> tuple[Message | None, list[MessageContentDelta]]: """Returns a tuple of message snapshot and newly created text message deltas""" + from ...types.beta.threads import MessageContent + if event.event == "thread.message.created": return event.data, [] diff --git a/tests/lib/test_streaming_lazy_types.py b/tests/lib/test_streaming_lazy_types.py new file mode 100644 index 0000000000..10528ac687 --- /dev/null +++ b/tests/lib/test_streaming_lazy_types.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import sys +import subprocess + +import openai + + +def _modules_after_import_openai() -> set[str]: + """Return the module names loaded by a bare `import openai` in a fresh interpreter.""" + output = subprocess.run( + [ + sys.executable, + "-c", + "import sys\nimport openai\nprint('\\n'.join(sys.modules))\n", + ], + check=True, + capture_output=True, + text=True, + ).stdout + return set(output.split()) + + +def test_import_openai_does_not_load_beta_types() -> None: + # `openai.lib.streaming` only needs `openai.types.beta` for annotations and for two + # narrow runtime paths, so importing the package must not pull the namespace in. + modules = _modules_after_import_openai() + + assert "openai" in modules + assert not [module for module in modules if module.startswith("openai.types.beta")] + + +def test_assistant_event_handlers_are_still_eagerly_exported() -> None: + assert openai.AssistantEventHandler.__name__ == "AssistantEventHandler" + assert openai.AsyncAssistantEventHandler.__name__ == "AsyncAssistantEventHandler" From 5e7b56d6b210062bb17b20445f65d15024a71e7a Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Fri, 28 Aug 2026 17:23:37 +0200 Subject: [PATCH 2/2] keep handler annotations resolvable: defer the module, not its imports The TYPE_CHECKING guard in _assistants.py made typing.get_type_hints raise NameError on the public handler methods, because postponed annotations are evaluated against that module's globals and the guarded names never bound. Defer openai.lib.streaming from the package instead. _assistants.py goes back to importing eagerly, so once anything touches the handlers their annotations resolve exactly as before, and 'import openai' still never loads the module. --- src/openai/__init__.py | 25 +++++++++++++++++---- src/openai/lib/streaming/_assistants.py | 30 ++++++++++--------------- tests/lib/test_streaming_lazy_types.py | 24 +++++++++++++++++--- 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/openai/__init__.py b/src/openai/__init__.py index 9b0b7badcc..f4401160b1 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -108,10 +108,27 @@ from .lib.azure import AzureOpenAI as AzureOpenAI, AsyncAzureOpenAI as AsyncAzureOpenAI from .lib.bedrock import BedrockOpenAI as BedrockOpenAI, AsyncBedrockOpenAI as AsyncBedrockOpenAI from .lib._old_api import * -from .lib.streaming import ( - AssistantEventHandler as AssistantEventHandler, - AsyncAssistantEventHandler as AsyncAssistantEventHandler, -) + +if _t.TYPE_CHECKING: + from .lib.streaming import ( + AssistantEventHandler as AssistantEventHandler, + AsyncAssistantEventHandler as AsyncAssistantEventHandler, + ) +else: + # `openai.lib.streaming` reaches `openai.types.beta`, which is 318 modules + # and about a third of the cost of `import openai`, for the Assistants API. + # Deferring the module keeps the names available on `openai` while leaving + # them -- and their annotations -- fully resolvable once anything asks. + _STREAMING_EXPORTS = ("AssistantEventHandler", "AsyncAssistantEventHandler") + + def __getattr__(__name: str) -> _t.Any: + if __name in _STREAMING_EXPORTS: + import importlib + + value = getattr(importlib.import_module("openai.lib.streaming"), __name) + globals()[__name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {__name!r}") _setup_logging() diff --git a/src/openai/lib/streaming/_assistants.py b/src/openai/lib/streaming/_assistants.py index a854fc2889..314961230d 100644 --- a/src/openai/lib/streaming/_assistants.py +++ b/src/openai/lib/streaming/_assistants.py @@ -10,20 +10,18 @@ from ..._httpx2 import timeout_exceptions from ..._models import construct_type from ..._streaming import Stream, AsyncStream - -if TYPE_CHECKING: - from ...types.beta import AssistantStreamEvent - from ...types.beta.threads import ( - Run, - Text, - Message, - ImageFile, - TextDelta, - MessageDelta, - MessageContent, - MessageContentDelta, - ) - from ...types.beta.threads.runs import RunStep, ToolCall, RunStepDelta, ToolCallDelta +from ...types.beta import AssistantStreamEvent +from ...types.beta.threads import ( + Run, + Text, + Message, + ImageFile, + TextDelta, + MessageDelta, + MessageContent, + MessageContentDelta, +) +from ...types.beta.threads.runs import RunStep, ToolCall, RunStepDelta, ToolCallDelta def _timeout_exceptions() -> tuple[type[Exception], ...]: @@ -905,8 +903,6 @@ def accumulate_run_step( return if event.event == "thread.run.step.delta": - from ...types.beta.threads.runs import RunStep - data = event.data snapshot = run_step_snapshots[data.id] @@ -932,8 +928,6 @@ def accumulate_event( current_message_snapshot: Message | None, ) -> tuple[Message | None, list[MessageContentDelta]]: """Returns a tuple of message snapshot and newly created text message deltas""" - from ...types.beta.threads import MessageContent - if event.event == "thread.message.created": return event.data, [] diff --git a/tests/lib/test_streaming_lazy_types.py b/tests/lib/test_streaming_lazy_types.py index 10528ac687..0485adde87 100644 --- a/tests/lib/test_streaming_lazy_types.py +++ b/tests/lib/test_streaming_lazy_types.py @@ -1,8 +1,11 @@ from __future__ import annotations import sys +import typing import subprocess +import pytest + import openai @@ -22,14 +25,29 @@ def _modules_after_import_openai() -> set[str]: def test_import_openai_does_not_load_beta_types() -> None: - # `openai.lib.streaming` only needs `openai.types.beta` for annotations and for two - # narrow runtime paths, so importing the package must not pull the namespace in. + # `openai.lib.streaming` is only needed by callers using the Assistants API, + # and it reaches `openai.types.beta`, so importing the package must not pull + # the namespace in. modules = _modules_after_import_openai() assert "openai" in modules assert not [module for module in modules if module.startswith("openai.types.beta")] -def test_assistant_event_handlers_are_still_eagerly_exported() -> None: +def test_assistant_event_handlers_are_still_exported() -> None: assert openai.AssistantEventHandler.__name__ == "AssistantEventHandler" assert openai.AsyncAssistantEventHandler.__name__ == "AsyncAssistantEventHandler" + + +def test_handler_annotations_stay_resolvable() -> None: + # Deferring the module must not make the handlers' postponed annotations + # unresolvable: `get_type_hints` has to keep working for annotation-aware + # integrations and documentation tooling. + for name in ("on_event", "on_run_step_delta", "on_tool_call_delta"): + hints = typing.get_type_hints(getattr(openai.AssistantEventHandler, name)) + assert "return" in hints + + +def test_unknown_attribute_still_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="definitely_not_an_export"): + openai.definitely_not_an_export # type: ignore[attr-defined] # noqa: B018