-
Notifications
You must be signed in to change notification settings - Fork 991
Add response wrappers for OpenAI Responses API streams. #4280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
aabmass
merged 17 commits into
open-telemetry:main
from
vasantteja:openai-v2-response-stream-wrappers
Mar 10, 2026
Merged
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
ab8bfd0
Add response wrappers for OpenAI Responses API streams.
vasantteja f9888e6
Enhance docstrings for ResponseStreamWrapper and ResponseStreamManage…
vasantteja 8d06bd6
Add wrappers for OpenAI Responses API streams and response stream man…
vasantteja f48d89a
Merge branch 'main' into openai-v2-response-stream-wrappers
vasantteja 2bce131
Refactor response handling in OpenAI response wrappers to improve eve…
vasantteja 9f9c25a
Merge branch 'openai-v2-response-stream-wrappers' of https://github.c…
vasantteja f20ee17
Refactor OpenAI response wrappers to enhance error handling and event…
vasantteja 6c53b95
Add unit tests for ResponseStreamManagerWrapper to validate error han…
vasantteja 63e1057
Remove unnecessary blank line in test_response_wrappers.py to improve…
vasantteja 1db06c7
Merge branch 'main' into openai-v2-response-stream-wrappers
vasantteja 5a5fa7e
Refactor event handling in ResponseStreamWrapper to simplify type che…
vasantteja 1f8b2b3
Merge branch 'openai-v2-response-stream-wrappers' of https://github.c…
vasantteja 7de8429
Enhance ResponseStreamWrapper and ResponseStreamManagerWrapper with i…
vasantteja 967fa24
Merge branch 'main' into openai-v2-response-stream-wrappers
vasantteja e5a4e7a
Merge branch 'main' into openai-v2-response-stream-wrappers
vasantteja a617c30
Merge branch 'main' into openai-v2-response-stream-wrappers
vasantteja bbc5710
Merge branch 'main' into openai-v2-response-stream-wrappers
vasantteja File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
314 changes: 314 additions & 0 deletions
314
...nstrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/response_wrappers.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,314 @@ | ||
| """Wrappers for OpenAI Responses API streams and stream managers.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from types import TracebackType | ||
| from typing import TYPE_CHECKING, Callable, Generic, TypeVar | ||
|
|
||
| try: | ||
| from openai.lib.streaming.responses._events import ( # pylint: disable=no-name-in-module | ||
| ResponseCompletedEvent, | ||
| ) | ||
| from openai.types.responses import ( # pylint: disable=no-name-in-module | ||
| ResponseCreatedEvent, | ||
| ResponseErrorEvent, | ||
| ResponseFailedEvent, | ||
| ResponseIncompleteEvent, | ||
| ResponseInProgressEvent, | ||
| ) | ||
| except ImportError: # pragma: no cover | ||
| ResponseCompletedEvent = None | ||
| ResponseCreatedEvent = None | ||
| ResponseErrorEvent = None | ||
| ResponseFailedEvent = None | ||
| ResponseIncompleteEvent = None | ||
| ResponseInProgressEvent = None | ||
|
|
||
| if ( | ||
| ResponseCreatedEvent is not None | ||
| and ResponseInProgressEvent is not None | ||
| and ResponseFailedEvent is not None | ||
| and ResponseIncompleteEvent is not None | ||
| and ResponseCompletedEvent is not None | ||
| ): | ||
| _RESPONSE_EVENTS_WITH_RESPONSE = ( | ||
|
vasantteja marked this conversation as resolved.
|
||
| ResponseCreatedEvent, | ||
| ResponseInProgressEvent, | ||
| ResponseFailedEvent, | ||
| ResponseIncompleteEvent, | ||
| ResponseCompletedEvent, | ||
| ) | ||
| else: | ||
| _RESPONSE_EVENTS_WITH_RESPONSE = () | ||
|
|
||
| try: | ||
| from opentelemetry.instrumentation.openai_v2.response_extractors import ( # pylint: disable=no-name-in-module | ||
| _set_invocation_response_attributes, | ||
| ) | ||
| except ImportError: # pragma: no cover | ||
| _set_invocation_response_attributes = None | ||
|
|
||
| try: | ||
| from opentelemetry.util.genai.types import ( # pylint: disable=no-name-in-module | ||
|
vasantteja marked this conversation as resolved.
Outdated
|
||
| Error, | ||
| ) | ||
| except ImportError: # pragma: no cover | ||
| Error = None | ||
|
|
||
| if TYPE_CHECKING: | ||
| from openai.lib.streaming.responses._events import ( # pylint: disable=no-name-in-module | ||
| ResponseStreamEvent, | ||
| ) | ||
| from openai.lib.streaming.responses._responses import ( | ||
| ResponseStream, | ||
| ResponseStreamManager, | ||
| ) # pylint: disable=no-name-in-module | ||
|
aabmass marked this conversation as resolved.
|
||
| from openai.types.responses import ( # pylint: disable=no-name-in-module | ||
| ParsedResponse, | ||
| Response, | ||
| ) | ||
|
|
||
| from opentelemetry.util.genai.handler import TelemetryHandler | ||
| from opentelemetry.util.genai.types import ( # pylint: disable=no-name-in-module | ||
| LLMInvocation, | ||
| ) | ||
|
|
||
|
|
||
| _logger = logging.getLogger(__name__) | ||
| TextFormatT = TypeVar("TextFormatT") | ||
| ResponseT = TypeVar("ResponseT") | ||
|
|
||
|
|
||
| def _set_response_attributes( | ||
| invocation: "LLMInvocation", | ||
| result: "ParsedResponse[TextFormatT] | Response | None", | ||
| capture_content: bool, | ||
| ) -> None: | ||
| if _set_invocation_response_attributes is None: | ||
| return | ||
| _set_invocation_response_attributes(invocation, result, capture_content) | ||
|
|
||
|
|
||
| class _ResponseProxy(Generic[ResponseT]): | ||
| def __init__(self, response: ResponseT, finalize: Callable[[], None]): | ||
| self._response = response | ||
| self._finalize = finalize | ||
|
|
||
| def close(self) -> None: | ||
| try: | ||
| self._response.close() | ||
| finally: | ||
| self._finalize() | ||
|
|
||
| def __getattr__(self, name: str): | ||
| return getattr(self._response, name) | ||
|
|
||
|
|
||
| class ResponseStreamWrapper(Generic[TextFormatT]): | ||
| """Wrapper for OpenAI Responses API stream objects. | ||
|
|
||
| Wraps ResponseStream from the OpenAI SDK: | ||
| https://github.com/openai/openai-python/blob/656e3cab4a18262a49b961d41293367e45ee71b9/src/openai/_streaming.py#L55 | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| stream: "ResponseStream[TextFormatT]", | ||
| handler: "TelemetryHandler", | ||
|
vasantteja marked this conversation as resolved.
Outdated
|
||
| invocation: "LLMInvocation", | ||
| capture_content: bool, | ||
| ): | ||
| self.stream = stream | ||
| self.handler = handler | ||
| self.invocation = invocation | ||
| self._capture_content = capture_content | ||
| self._finalized = False | ||
|
|
||
| def __enter__(self) -> "ResponseStreamWrapper": | ||
| return self | ||
|
|
||
| def __exit__( | ||
| self, | ||
| exc_type: type[BaseException] | None, | ||
| exc_val: BaseException | None, | ||
| exc_tb: TracebackType | None, | ||
| ) -> bool: | ||
| try: | ||
| if exc_type is not None: | ||
| self._fail( | ||
| str(exc_val), type(exc_val) if exc_val else Exception | ||
| ) | ||
| finally: | ||
| self.close() | ||
| return False | ||
|
|
||
| def close(self) -> None: | ||
| try: | ||
| self.stream.close() | ||
| finally: | ||
| self._stop(None) | ||
|
|
||
| def __iter__(self) -> "ResponseStreamWrapper": | ||
| return self | ||
|
|
||
| def __next__(self) -> "ResponseStreamEvent[TextFormatT]": | ||
| try: | ||
| event = next(self.stream) | ||
| except StopIteration: | ||
| self._stop(None) | ||
| raise | ||
| except Exception as error: | ||
| self._fail(str(error), type(error)) | ||
| raise | ||
| self._safe_instrumentation( | ||
| lambda: self.process_event(event), | ||
| "event processing", | ||
| ) | ||
| return event | ||
|
|
||
| def get_final_response(self) -> "ParsedResponse[TextFormatT]": | ||
| self.until_done() | ||
| return self.stream.get_final_response() | ||
|
|
||
| def until_done(self) -> "ResponseStreamWrapper": | ||
| for _ in self: | ||
| pass | ||
| return self | ||
|
|
||
| def parse(self) -> "ResponseStreamWrapper": | ||
|
vasantteja marked this conversation as resolved.
|
||
| return self | ||
|
|
||
| def __getattr__(self, name: str): | ||
| return getattr(self.stream, name) | ||
|
vasantteja marked this conversation as resolved.
|
||
|
|
||
| @property | ||
| def response(self): | ||
| response = self.stream.response | ||
| if response is None: | ||
| return None | ||
| return _ResponseProxy(response, lambda: self._stop(None)) | ||
|
vasantteja marked this conversation as resolved.
|
||
|
|
||
| def _stop( | ||
| self, result: "ParsedResponse[TextFormatT] | Response | None" | ||
| ) -> None: | ||
| if self._finalized: | ||
| return | ||
| self._safe_instrumentation( | ||
| lambda: _set_response_attributes( | ||
| self.invocation, result, self._capture_content | ||
| ), | ||
| "response attribute extraction", | ||
| ) | ||
| self._safe_instrumentation( | ||
| lambda: self.handler.stop_llm(self.invocation), | ||
| "stop_llm", | ||
| ) | ||
| self._finalized = True | ||
|
|
||
| def _fail(self, message: str, error_type: type[BaseException]) -> None: | ||
| if self._finalized: | ||
| return | ||
| if Error is None: | ||
| return | ||
| self._safe_instrumentation( | ||
| lambda: self.handler.fail_llm( | ||
| self.invocation, Error(message=message, type=error_type) | ||
| ), | ||
| "fail_llm", | ||
| ) | ||
| self._finalized = True | ||
|
|
||
| @staticmethod | ||
| def _safe_instrumentation( | ||
| callback: Callable[[], None], context: str | ||
| ) -> None: | ||
|
vasantteja marked this conversation as resolved.
Outdated
|
||
| try: | ||
| callback() | ||
| except Exception: # pylint: disable=broad-exception-caught | ||
| _logger.debug( | ||
|
vasantteja marked this conversation as resolved.
|
||
| "OpenAI responses instrumentation error during %s", | ||
| context, | ||
| exc_info=True, | ||
| ) | ||
|
|
||
| def process_event(self, event: "ResponseStreamEvent[TextFormatT]") -> None: | ||
| event_type = event.type | ||
| response: "ParsedResponse[TextFormatT] | Response | None" = None | ||
|
|
||
| if isinstance(event, _RESPONSE_EVENTS_WITH_RESPONSE): | ||
| response = event.response | ||
|
|
||
| if response and not self.invocation.request_model: | ||
| model = response.model | ||
| if model: | ||
| self.invocation.request_model = model | ||
|
|
||
| if ResponseCompletedEvent is not None and isinstance( | ||
| event, ResponseCompletedEvent | ||
| ): | ||
|
vasantteja marked this conversation as resolved.
Outdated
|
||
| self._stop(response) | ||
| return | ||
|
|
||
| if ( | ||
| ResponseFailedEvent is not None | ||
| and ResponseIncompleteEvent is not None | ||
| and isinstance( | ||
| event, (ResponseFailedEvent, ResponseIncompleteEvent) | ||
| ) | ||
| ): | ||
| self._safe_instrumentation( | ||
| lambda: _set_response_attributes( | ||
| self.invocation, response, self._capture_content | ||
| ), | ||
| "response attribute extraction", | ||
| ) | ||
| self._fail(event_type, RuntimeError) | ||
| return | ||
|
|
||
| if ResponseErrorEvent is not None and isinstance( | ||
| event, ResponseErrorEvent | ||
| ): | ||
| error_type = event.code or "response.error" | ||
| message = event.message or error_type | ||
| self._fail(message, RuntimeError) | ||
|
|
||
|
|
||
| class ResponseStreamManagerWrapper(Generic[TextFormatT]): | ||
| """Wrapper for OpenAI Responses API stream managers. | ||
|
|
||
| Wraps ResponseStreamManager from the OpenAI SDK: | ||
| https://github.com/openai/openai-python/blob/656e3cab4a18262a49b961d41293367e45ee71b9/src/openai/lib/streaming/responses/_responses.py#L95 | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| manager: "ResponseStreamManager[TextFormatT]", | ||
| handler: "TelemetryHandler", | ||
| invocation: "LLMInvocation", | ||
| capture_content: bool, | ||
| ): | ||
| self._manager = manager | ||
| self._handler = handler | ||
| self._invocation = invocation | ||
| self._capture_content = capture_content | ||
|
|
||
| def __enter__(self) -> ResponseStreamWrapper[TextFormatT]: | ||
| stream = self._manager.__enter__() | ||
| return ResponseStreamWrapper( | ||
| stream, | ||
| self._handler, | ||
| self._invocation, | ||
| self._capture_content, | ||
| ) | ||
|
|
||
| def __exit__( | ||
| self, | ||
| exc_type: type[BaseException] | None, | ||
| exc_val: BaseException | None, | ||
| exc_tb: TracebackType | None, | ||
| ) -> bool: | ||
| return self._manager.__exit__(exc_type, exc_val, exc_tb) | ||
|
vasantteja marked this conversation as resolved.
Outdated
|
||
|
|
||
| def __getattr__(self, name: str): | ||
|
vasantteja marked this conversation as resolved.
|
||
| return getattr(self._manager, name) | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.