# Copyright (c) Microsoft. All rights reserved.

from __future__ import annotations

import json
import logging
import shlex
import sys
from collections.abc import (
    AsyncIterable,
    Awaitable,
    Callable,
    Mapping,
    MutableMapping,
    Sequence,
)
from datetime import datetime, timezone
from itertools import chain
from typing import (
    TYPE_CHECKING,
    Any,
    ClassVar,
    Generic,
    Literal,
    NoReturn,
    TypedDict,
    cast,
    overload,
)

from agent_framework._clients import BaseChatClient
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
from agent_framework._settings import SecretString
from agent_framework._telemetry import USER_AGENT_KEY
from agent_framework._tools import (
    SHELL_TOOL_KIND_VALUE,
    FunctionInvocationConfiguration,
    FunctionInvocationLayer,
    FunctionTool,
    ToolTypes,
    normalize_tools,
    tool,
)
from agent_framework._types import (
    Annotation,
    ChatOptions,
    ChatResponse,
    ChatResponseUpdate,
    Content,
    ContinuationToken,
    Message,
    ResponseStream,
    Role,
    TextSpanRegion,
    UsageDetails,
    detect_media_type_from_base64,
    prepend_instructions_to_messages,
    validate_tool_mode,
)
from agent_framework.exceptions import (
    ChatClientException,
    ChatClientInvalidRequestException,
)
from agent_framework.observability import ChatTelemetryLayer
from openai import AsyncAzureOpenAI, AsyncOpenAI, BadRequestError
from openai.types.responses import FunctionShellToolParam, ResponseCustomToolCall, ResponseToolSearchCall
from openai.types.responses.file_search_tool_param import FileSearchToolParam
from openai.types.responses.function_tool_param import FunctionToolParam
from openai.types.responses.parsed_response import (
    ParsedResponse,
)
from openai.types.responses.response import Response as OpenAIResponse
from openai.types.responses.response_stream_event import (
    ResponseStreamEvent as OpenAIResponseStreamEvent,
)
from openai.types.responses.response_usage import ResponseUsage
from openai.types.responses.tool_param import (
    CodeInterpreter,
    CodeInterpreterContainerCodeInterpreterToolAuto,
    ImageGeneration,
    Mcp,
)
from openai.types.responses.web_search_tool_param import WebSearchToolParam
from pydantic import BaseModel

from ._exceptions import OpenAIContentFilterException
from ._shared import (
    AzureTokenProvider,
    load_openai_service_settings,
    maybe_append_azure_endpoint_guidance,
)

if sys.version_info >= (3, 13):
    from typing import TypeVar  # pragma: no cover
else:
    from typing_extensions import TypeVar  # pragma: no cover
if sys.version_info >= (3, 12):
    from typing import override  # pragma: no cover
else:
    from typing_extensions import override  # pragma: no cover
if sys.version_info >= (3, 11):
    from typing import TypedDict  # pragma: no cover
else:
    from typing_extensions import TypedDict  # pragma: no cover

if TYPE_CHECKING:
    from azure.core.credentials import TokenCredential
    from azure.core.credentials_async import AsyncTokenCredential

    AzureCredentialTypes = TokenCredential | AsyncTokenCredential

logger = logging.getLogger("agent_framework.openai")

DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview"

OPENAI_SHELL_ENVIRONMENT_KEY = "openai.responses.shell.environment"
OPENAI_SHELL_OUTPUT_TYPE_KEY = "openai.responses.shell.output_type"
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY = "openai.responses.local_shell.call_item_id"
OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY = "openai.local_shell_command_parts"
OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL = "shell_call_output"
OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL = "local_shell_call_output"
_AZURE_AI_SEARCH_CALL_OUTPUT_TYPE = "azure_ai_search_call_output"
_AZURE_AI_SEARCH_OUTPUT_EVENT_TYPES = {"response.output_item.added", "response.output_item.done"}
_AZURE_AI_SEARCH_OUTPUT_EVENT_PREFIX = "response.azure_ai_search_call_output."

# Internal marker emitted by `_prepare_content_for_openai` for an
# `mcp_server_tool_result` Content. The Responses API expects an `mcp_call`
# input item to carry both arguments and output as one item, so result
# Contents cannot be serialized standalone. `_prepare_messages_for_openai`
# coalesces these markers into the most recent matching `mcp_call` input
# item before returning, dropping any that are unmatched.
_AF_MCP_PENDING_OUTPUT_KEY = "__af_pending_mcp_result__"


def _mcp_call_ids_paired_with_reasoning(messages: Sequence[Message]) -> set[str]:
    paired_call_ids: set[str] = set()
    pending_reasoning_prefix = False

    for message in messages:
        has_reasoning = any(content.type == "text_reasoning" for content in message.contents)
        has_mcp_call = False
        for content in message.contents:
            if content.type != "mcp_server_tool_call":
                continue
            has_mcp_call = True
            if (has_reasoning or pending_reasoning_prefix) and content.call_id:
                paired_call_ids.add(content.call_id)

        if has_mcp_call:
            pending_reasoning_prefix = False
        elif (
            message.role == "assistant"
            and message.contents
            and all(content.type == "text_reasoning" for content in message.contents)
        ):
            pending_reasoning_prefix = True
        else:
            pending_reasoning_prefix = False

    return paired_call_ids


class OpenAIContinuationToken(ContinuationToken):
    """Continuation token for OpenAI Responses API background operations."""

    response_id: str
    """OpenAI Responses API response ID."""


# region OpenAI Responses Options TypedDict


class ReasoningOptions(TypedDict, total=False):
    """Configuration options for reasoning models (gpt-5, o-series).

    See: https://platform.openai.com/docs/guides/reasoning
    """

    effort: Literal["none", "low", "medium", "high", "xhigh"]
    """The effort level for reasoning. Higher effort means more reasoning tokens."""

    summary: Literal["auto", "concise", "detailed"]
    """How to summarize reasoning in the response."""


class StreamOptions(TypedDict, total=False):
    """Options for streaming responses."""

    include_usage: bool
    """Whether to include usage statistics in stream events."""


ResponseFormatT = TypeVar("ResponseFormatT", bound=BaseModel | None, default=None)


class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], total=False):
    """OpenAI Responses API-specific chat options.

    Extends ChatOptions with options specific to OpenAI's Responses API.
    These options provide fine-grained control over response generation,
    reasoning, and API behavior.

    See: https://platform.openai.com/docs/api-reference/responses/create
    """

    # Responses API-specific parameters

    include: list[str]
    """Additional output data to include in the response.
    Supported values include:
    - 'web_search_call.action.sources'
    - 'code_interpreter_call.outputs'
    - 'file_search_call.results'
    - 'message.input_image.image_url'
    - 'message.output_text.logprobs'
    - 'reasoning.encrypted_content'
    """

    max_tool_calls: int
    """Maximum number of total calls to built-in tools in a response."""

    prompt: dict[str, Any]
    """Reference to a prompt template and its variables.
    Learn more: https://platform.openai.com/docs/guides/text#reusable-prompts"""

    prompt_cache_key: str
    """Used by OpenAI to cache responses for similar requests.
    Replaces the deprecated 'user' field for caching purposes."""

    prompt_cache_retention: Literal["24h"]
    """Retention policy for prompt cache. Set to '24h' for extended caching."""

    reasoning: ReasoningOptions
    """Configuration for reasoning models (gpt-5, o-series).
    See: https://platform.openai.com/docs/guides/reasoning"""

    verbosity: Literal["low", "medium", "high"]
    """Output verbosity for GPT-5 family models. Lower values yield shorter responses.
    Translated to ``text.verbosity`` when sent to the Responses API.
    See: https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter"""

    safety_identifier: str
    """A stable identifier for detecting policy violations.
    Recommend hashing username/email to avoid sending identifying info."""

    service_tier: Literal["auto", "default", "flex", "priority"]
    """Processing type for serving the request.
    - 'auto': Use project settings
    - 'default': Standard pricing/performance
    - 'flex': Flexible processing
    - 'priority': Priority processing"""

    stream_options: StreamOptions
    """Options for streaming responses. Only set when stream=True."""

    top_logprobs: int
    """Number of most likely tokens (0-20) to return at each position."""

    truncation: Literal["auto", "disabled"]
    """Truncation strategy for model response.
    - 'auto': Truncate from beginning if exceeds context
    - 'disabled': Fail with 400 error if exceeds context"""

    background: bool
    """Whether to run the model response in the background.
    When True, the response returns immediately with a continuation token
    that can be used to poll for the result.
    See: https://platform.openai.com/docs/guides/background"""

    continuation_token: OpenAIContinuationToken
    """Token for resuming or polling a long-running background operation.
    Pass the ``continuation_token`` from a previous response to poll for
    completion or resume a streaming response."""


OpenAIChatOptionsT = TypeVar(
    "OpenAIChatOptionsT",
    bound=TypedDict,  # type: ignore[valid-type]
    default="OpenAIChatOptions",
    covariant=True,
)


# endregion


# region Helpers


def _annotations_to_output_text(annotations: Sequence[Annotation] | None) -> list[dict[str, Any]]:
    """Convert framework `Annotation` objects to Responses API `output_text` annotation dicts.

    Citations from `file_search`, `code_interpreter` file paths, and url citations all collapse
    to `Annotation(type="citation", ...)` in the framework. The original API form is recovered
    here so assistant messages roundtrip cleanly through history forwarding.

    Each Responses API annotation dict carries at most one `start_index`/`end_index` pair, so an
    `Annotation` with multiple `annotated_regions` is fanned out into one entry per region.
    Regions missing valid integer span bounds are skipped.
    """
    if not annotations:
        return []
    out: list[dict[str, Any]] = []
    for annotation in annotations:
        if annotation.get("type") != "citation":
            continue
        props = annotation.get("additional_properties") or {}
        regions = annotation.get("annotated_regions") or []
        file_id = annotation.get("file_id")
        url = annotation.get("url")
        title = annotation.get("title")
        container_id = props.get("container_id")

        if container_id and file_id:
            for region in regions:
                start = region.get("start_index")
                end = region.get("end_index")
                if not (isinstance(start, int) and isinstance(end, int)):
                    continue
                entry: dict[str, Any] = {
                    "type": "container_file_citation",
                    "container_id": container_id,
                    "file_id": file_id,
                    "start_index": start,
                    "end_index": end,
                }
                if url:
                    entry["filename"] = url
                out.append(entry)
        elif url and not file_id and regions:
            for region in regions:
                start = region.get("start_index")
                end = region.get("end_index")
                if not (isinstance(start, int) and isinstance(end, int)):
                    continue
                out.append({
                    "type": "url_citation",
                    "url": url,
                    "title": title or "",
                    "start_index": start,
                    "end_index": end,
                })
        elif file_id and url:
            entry = {
                "type": "file_citation",
                "file_id": file_id,
                "filename": url,
            }
            if (idx := props.get("index")) is not None:
                entry["index"] = idx
            out.append(entry)
        elif file_id:
            entry = {
                "type": "file_path",
                "file_id": file_id,
            }
            if (idx := props.get("index")) is not None:
                entry["index"] = idx
            out.append(entry)
    return out


# endregion


# region ResponsesClient


class RawOpenAIChatClient(
    BaseChatClient[OpenAIChatOptionsT],
    Generic[OpenAIChatOptionsT],
):
    """Raw OpenAI Responses client without middleware, telemetry, or function invocation.

    Warning:
        **This class should not normally be used directly.** It does not include middleware,
        telemetry, or function invocation support that you most likely need. If you do use it,
        you should consider which additional layers to apply. There is a defined ordering that
        you should follow:

        1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware
        2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
        3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry

        Use ``OpenAIChatClient`` instead for a fully-featured client with all layers applied.
    """

    INJECTABLE: ClassVar[set[str]] = {"client"}
    STORES_BY_DEFAULT: ClassVar[bool] = True
    SUPPORTS_RICH_FUNCTION_OUTPUT: ClassVar[bool] = True

    # Azure OpenAI Responses API may include this header in responses naming the actual model that
    # served the request (e.g. ``gpt-5-nano-2025-08-07``), which can differ from the deployment alias
    # that the request was addressed to and that ``response.model`` reports. When present, we use it
    # as the value of ``ChatResponse.model`` / ``ChatResponseUpdate.model`` so telemetry and callers
    # see the actually served model. (Chat Completions API already returns the snapshot in
    # ``response.model``, so this header only matters for the Responses API.)
    SERVED_MODEL_HEADER: ClassVar[str] = "x-ms-served-model"

    FILE_SEARCH_MAX_RESULTS: int = 50

    @overload
    def __init__(
        self,
        model: str | None = None,
        *,
        api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None,
        org_id: str | None = None,
        base_url: str | None = None,
        default_headers: Mapping[str, str] | None = None,
        async_client: AsyncOpenAI | None = None,
        instruction_role: str | None = None,
        compaction_strategy: CompactionStrategy | None = None,
        tokenizer: TokenizerProtocol | None = None,
        additional_properties: dict[str, Any] | None = None,
        env_file_path: str | None = None,
        env_file_encoding: str | None = None,
        timeout: float | None = None,
    ) -> None:
        """Initialize a raw OpenAI Chat client.

        Keyword Args:
            model: Model identifier to use for the request. When not provided, the constructor
                reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``.
            api_key: API key. When not provided explicitly, the constructor reads
                ``OPENAI_API_KEY``. A callable API key is also supported.
            org_id: OpenAI organization ID. When not provided explicitly, the constructor reads
                ``OPENAI_ORG_ID``.
            base_url: Base URL override. When not provided explicitly, the constructor reads
                ``OPENAI_BASE_URL``.
            default_headers: Additional HTTP headers.
            async_client: Pre-configured OpenAI client.
            instruction_role: Role for instruction messages (for example ``"system"``).
            compaction_strategy: Optional per-client compaction override.
            tokenizer: Optional tokenizer for compaction strategies.
            additional_properties: Additional properties stored on the client instance.
            env_file_path: Optional ``.env`` file that is checked before the process environment
                for ``OPENAI_*`` values.
            env_file_encoding: Encoding for the ``.env`` file.
            timeout: Optional timeout in seconds for requests.
        """
        ...

    @overload
    def __init__(
        self,
        model: str | None = None,
        *,
        azure_endpoint: str,
        credential: AzureCredentialTypes | AzureTokenProvider | None = None,
        api_version: str | None = None,
        api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None,
        base_url: str | None = None,
        default_headers: Mapping[str, str] | None = None,
        async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None,
        instruction_role: str | None = None,
        compaction_strategy: CompactionStrategy | None = None,
        tokenizer: TokenizerProtocol | None = None,
        additional_properties: dict[str, Any] | None = None,
        env_file_path: str | None = None,
        env_file_encoding: str | None = None,
        timeout: float | None = None,
    ) -> None:
        """Initialize a raw OpenAI Chat client.

        Keyword Args:
            model: Model identifier to use for the request. When not provided, the constructor
                reads ``AZURE_OPENAI_CHAT_MODEL`` and then
                ``AZURE_OPENAI_MODEL``.
            azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor
                reads ``AZURE_OPENAI_ENDPOINT``.
            credential: Azure credential or token provider for Entra auth.
            api_version: Azure API version. When not provided explicitly, the constructor reads
                ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default.
            api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key
                auth. A callable token provider is also accepted,
                but ``credential`` is the preferred Azure auth surface.
            base_url: Base URL override. When not provided explicitly, the constructor reads
                ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want
                to pass the full ``.../openai/v1`` base URL directly.
            default_headers: Additional HTTP headers.
            async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on
                Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup.
            instruction_role: Role for instruction messages (for example ``"system"``).
            compaction_strategy: Optional per-client compaction override.
            tokenizer: Optional tokenizer for compaction strategies.
            additional_properties: Additional properties stored on the client instance.
            env_file_path: Optional ``.env`` file that is checked before process environment
                variables for ``AZURE_OPENAI_*`` values.
            env_file_encoding: Encoding for the ``.env`` file.
            timeout: Optional timeout in seconds for requests.
        """
        ...

    def __init__(
        self,
        model: str | None = None,
        *,
        api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None,
        credential: AzureCredentialTypes | AzureTokenProvider | None = None,
        org_id: str | None = None,
        base_url: str | None = None,
        azure_endpoint: str | None = None,
        api_version: str | None = None,
        default_headers: Mapping[str, str] | None = None,
        async_client: AsyncOpenAI | None = None,
        instruction_role: str | None = None,
        compaction_strategy: CompactionStrategy | None = None,
        tokenizer: TokenizerProtocol | None = None,
        additional_properties: dict[str, Any] | None = None,
        env_file_path: str | None = None,
        env_file_encoding: str | None = None,
        timeout: float | None = None,
    ) -> None:
        """Initialize a raw OpenAI Chat client.

        Keyword Args:
            model: Model identifier to use for the request. When not provided, the constructor
                reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI,
                or ``AZURE_OPENAI_CHAT_MODEL`` and then ``AZURE_OPENAI_MODEL`` for Azure.
            api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``.
                For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key
                auth. A callable token provider is also accepted for backwards compatibility,
                but ``credential`` is the preferred Azure auth surface.
            credential: Azure credential or token provider for Azure OpenAI auth. Passing this
                is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured.
                Credential objects require the optional ``azure-identity`` package.
            org_id: OpenAI organization ID. Used only for OpenAI and resolved from
                ``OPENAI_ORG_ID`` when not provided.
            base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``.
                For Azure this may be used instead of ``azure_endpoint`` when you want
                to pass the full ``.../openai/v1`` base URL directly.
            azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure
                falls back to ``AZURE_OPENAI_ENDPOINT``.
            api_version: Azure API version to use once Azure routing is selected. When
                not provided explicitly, Azure routing falls back to
                ``AZURE_OPENAI_API_VERSION`` and then the Responses default.
            default_headers: Additional HTTP headers.
            async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on
                Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup.
            instruction_role: Role for instruction messages (for example ``"system"``).
            compaction_strategy: Optional per-client compaction override.
            tokenizer: Optional tokenizer for compaction strategies.
            additional_properties: Additional properties stored on the client instance.
            env_file_path: Optional ``.env`` file that is checked before process environment
                variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*``
                lookups.
            env_file_encoding: Encoding for the ``.env`` file.
            timeout: HTTP timeout in seconds for requests. When not provided, the
                OpenAI SDK default is used (connect: 5s, total: 600s).

        Notes:
            Environment resolution and routing precedence are:

            1. Explicit Azure inputs (``azure_endpoint`` or ``credential``)
            2. Explicit OpenAI API key or ``OPENAI_API_KEY``
            3. Azure environment fallback

            OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``,
            ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing
            reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``,
            ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_MODEL``,
            ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``.
        """
        settings, client, use_azure_client = load_openai_service_settings(
            model=model,
            api_key=api_key,
            credential=credential,
            org_id=org_id,
            base_url=base_url,
            endpoint=azure_endpoint,
            api_version=api_version,
            default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION,
            default_headers=default_headers,
            client=async_client,
            env_file_path=env_file_path,
            env_file_encoding=env_file_encoding,
            openai_model_fields=("chat_model", "model"),
            azure_model_fields=("chat_model", "model"),
            responses_mode=True,
            timeout=timeout,
        )

        self.client = client
        self.model: str = settings.get("model") or ""

        # Store configuration for serialization
        self.org_id = settings.get("org_id")
        self.base_url = settings.get("base_url")
        self.azure_endpoint = settings.get("endpoint")
        self.api_version = settings.get("api_version")
        if default_headers:
            self.default_headers: dict[str, Any] | None = {
                k: v for k, v in default_headers.items() if k != USER_AGENT_KEY
            }
        else:
            self.default_headers = None
        self.instruction_role = instruction_role
        if use_azure_client:
            self.OTEL_PROVIDER_NAME = "azure.ai.openai"  # type: ignore[misc]

        super().__init__(
            compaction_strategy=compaction_strategy,
            tokenizer=tokenizer,
            additional_properties=additional_properties,
        )

    # region Inner Methods

    async def _prepare_request(
        self,
        messages: Sequence[Message],
        options: Mapping[str, Any],
    ) -> tuple[AsyncOpenAI, dict[str, Any], dict[str, Any]]:
        """Validate options and prepare the request.

        Returns:
            Tuple of (client, run_options, validated_options).
        """
        client = self.client
        validated_options = await self._validate_options(options)
        run_options = await self._prepare_options(messages, validated_options)
        return client, run_options, validated_options

    def _handle_request_error(self, ex: Exception) -> NoReturn:
        """Convert exceptions to appropriate service exceptions. Always raises."""
        if isinstance(ex, BadRequestError) and ex.code == "content_filter":
            raise OpenAIContentFilterException(
                f"{type(self)} service encountered a content error: {ex}",
                inner_exception=ex,
            ) from ex
        raise ChatClientException(
            maybe_append_azure_endpoint_guidance(
                f"{type(self)} service failed to complete the prompt: {ex}",
                azure_endpoint=self.azure_endpoint,
            ),
            inner_exception=ex,
        ) from ex

    @override
    def _inner_get_response(
        self,
        *,
        messages: Sequence[Message],
        options: Mapping[str, Any],
        stream: bool = False,
        **kwargs: Any,
    ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
        continuation_token: OpenAIContinuationToken | None = options.get("continuation_token")

        if stream:
            function_call_ids: dict[int, tuple[str, str]] = {}
            seen_reasoning_delta_item_ids: set[str] = set()
            validated_options: dict[str, Any] | None = None
            # Captured once request options are validated/prepared so the streaming finalizer can
            # still parse the aggregated response into structured output after the stream completes.
            response_format: Any | None = None

            def _finalize_with_captured_format(updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]:
                # ResponseStream only calls the finalizer after iterating or draining `_stream()`,
                # so `response_format` has already been populated from the validated request state
                # unless request setup failed before streaming began.
                return self._finalize_response_updates(updates, response_format=response_format)

            async def _stream() -> AsyncIterable[ChatResponseUpdate]:
                nonlocal response_format, validated_options
                if continuation_token is not None:
                    # Resume a background streaming response by retrieving with stream=True
                    client = self.client
                    validated_options = await self._validate_options(options)
                    response_format = validated_options.get("response_format")
                    try:
                        raw_stream_response = await client.responses.with_raw_response.retrieve(
                            continuation_token["response_id"],
                            stream=True,
                        )
                        # Read headers defensively: telemetry instrumentors (e.g. azure-ai-projects
                        # experimental tracing) wrap the streaming response in objects that do not
                        # proxy ``.headers``. Degrade gracefully so the served-model surfacing is
                        # best-effort instead of crashing the whole call.
                        served_model = self._extract_served_model(getattr(raw_stream_response, "headers", None))
                        async with raw_stream_response.parse() as stream_response:
                            async for chunk in stream_response:
                                update = self._parse_chunk_from_openai(
                                    chunk,
                                    options=validated_options,
                                    function_call_ids=function_call_ids,
                                    seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
                                )
                                if served_model is not None:
                                    update.model = served_model
                                yield update
                    except Exception as ex:
                        self._handle_request_error(ex)
                else:
                    (
                        client,
                        run_options,
                        validated_options,
                    ) = await self._prepare_request(messages, options)
                    response_format = validated_options.get("response_format")
                    try:
                        if "text_format" in run_options:
                            # The SDK's ``responses.stream(text_format=...)`` helper preserves
                            # client-side ``output_parsed`` partial parsing for structured outputs,
                            # but it does not expose the raw HTTP response (no ``x-ms-served-model``
                            # access). We accept that trade-off: this single streaming path keeps
                            # the deployment alias as the reported model name. All other paths
                            # surface the served-model header.
                            async with client.responses.stream(**run_options) as response:
                                async for chunk in response:
                                    yield self._parse_chunk_from_openai(
                                        chunk,
                                        options=validated_options,
                                        function_call_ids=function_call_ids,
                                        seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
                                    )
                        else:
                            raw_create_response = await client.responses.with_raw_response.create(
                                stream=True, **run_options
                            )
                            # See note above on ``raw_stream_response.headers``.
                            served_model = self._extract_served_model(getattr(raw_create_response, "headers", None))
                            async with raw_create_response.parse() as stream_response:
                                async for chunk in stream_response:
                                    update = self._parse_chunk_from_openai(
                                        chunk,
                                        options=validated_options,
                                        function_call_ids=function_call_ids,
                                        seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
                                    )
                                    if served_model is not None:
                                        update.model = served_model
                                    yield update
                    except Exception as ex:
                        self._handle_request_error(ex)

            return ResponseStream(_stream(), finalizer=_finalize_with_captured_format)

        # Non-streaming
        async def _get_response() -> ChatResponse:
            if continuation_token is not None:
                # Poll a background response by retrieving without stream
                client = self.client
                validated_options = await self._validate_options(options)
                try:
                    raw_response = await client.responses.with_raw_response.retrieve(continuation_token["response_id"])
                    response = raw_response.parse()
                except Exception as ex:
                    self._handle_request_error(ex)
                chat_response = self._parse_response_from_openai(response, options=validated_options)
                # See note above on ``raw_stream_response.headers``.
                served_model = self._extract_served_model(getattr(raw_response, "headers", None))
                if served_model is not None:
                    chat_response.model = served_model
                # Once the background response completes, drop the continuation_token from
                # the caller's options dict. FunctionInvocationLayer reuses the same dict
                # across tool-loop iterations, so leaving it in place makes the next iteration
                # retrieve the same completed response again instead of POSTing tool results
                # (issue #5394). Keep `background` so subsequent iterations still create
                # background responses.
                if chat_response.continuation_token is None and isinstance(options, dict):
                    options.pop("continuation_token", None)
                return chat_response
            client, run_options, validated_options = await self._prepare_request(messages, options)
            try:
                if "text_format" in run_options:
                    raw_response = await client.responses.with_raw_response.parse(stream=False, **run_options)
                else:
                    raw_response = await client.responses.with_raw_response.create(stream=False, **run_options)
                response = raw_response.parse()
            except Exception as ex:
                self._handle_request_error(ex)
            chat_response = self._parse_response_from_openai(response, options=validated_options)
            # See note above on ``raw_stream_response.headers``.
            served_model = self._extract_served_model(getattr(raw_response, "headers", None))
            if served_model is not None:
                chat_response.model = served_model
            return chat_response

        return _get_response()

    @override
    def _finalize_response_updates(
        self,
        updates: Sequence[ChatResponseUpdate],
        *,
        response_format: Any | None = None,
    ) -> ChatResponse[Any]:
        """Finalize streamed updates and add post-stream Azure AI Search citation metadata."""
        self._enrich_streamed_azure_ai_search_citations(updates)
        self._enrich_mcp_search_citations([content for update in updates for content in update.contents])
        return super()._finalize_response_updates(updates, response_format=response_format)

    @classmethod
    def _extract_served_model(cls, headers: Any) -> str | None:
        """Return the Azure OpenAI ``x-ms-served-model`` response header value when present.

        Azure OpenAI Responses API returns the deployment alias in ``response.model`` but the actual
        snapshot served via the ``x-ms-served-model`` response header (e.g. ``gpt-5-nano-2025-08-07``
        vs deployment alias ``gpt-5-nano``). When present, the served snapshot is the source of truth
        for observability and downstream callers. Empty/whitespace-only header values are rejected
        here so every caller can simply check ``if served_model is not None``.
        """
        if headers is None:
            return None
        served_model = headers.get(cls.SERVED_MODEL_HEADER)
        if isinstance(served_model, str):
            stripped = served_model.strip()
            if stripped:
                return stripped
        return None

    def _prepare_response_and_text_format(
        self,
        *,
        response_format: Any,
        text_config: MutableMapping[str, Any] | None,
    ) -> tuple[type[BaseModel] | None, dict[str, Any] | None]:
        """Normalize response_format into Responses text configuration and parse target."""
        if text_config is not None and not isinstance(text_config, MutableMapping):
            raise ChatClientInvalidRequestException("text must be a mapping when provided.")
        text_config = cast(dict[str, Any], text_config) if isinstance(text_config, MutableMapping) else None

        if response_format is None:
            return None, text_config

        if isinstance(response_format, type) and issubclass(response_format, BaseModel):
            if text_config and "format" in text_config:
                raise ChatClientInvalidRequestException("response_format cannot be combined with explicit text.format.")
            return response_format, text_config

        if isinstance(response_format, Mapping):
            format_config = self._convert_response_format(cast("Mapping[str, Any]", response_format))
            if text_config is None:
                text_config = {}
            elif "format" in text_config and text_config["format"] != format_config:
                raise ChatClientInvalidRequestException("Conflicting response_format definitions detected.")
            text_config["format"] = format_config
            return None, text_config

        raise ChatClientInvalidRequestException("response_format must be a Pydantic model or mapping.")

    def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[str, Any]:
        """Convert Chat style response_format into Responses text format config."""
        if "format" in response_format and isinstance(response_format["format"], Mapping):
            return dict(cast("Mapping[str, Any]", response_format["format"]))

        format_type = response_format.get("type")
        if format_type == "json_schema":
            schema_section = response_format.get("json_schema", response_format)
            if not isinstance(schema_section, Mapping):
                raise ChatClientInvalidRequestException("json_schema response_format must be a mapping.")
            schema_section_typed = cast("Mapping[str, Any]", schema_section)
            schema: Any = schema_section_typed.get("schema")
            if schema is None:
                raise ChatClientInvalidRequestException("json_schema response_format requires a schema.")
            name: str = str(
                schema_section_typed.get("name")
                or schema_section_typed.get("title")
                or (cast("Mapping[str, Any]", schema).get("title") if isinstance(schema, Mapping) else None)
                or "response"
            )
            format_config: dict[str, Any] = {
                "type": "json_schema",
                "name": name,
                "schema": schema,
            }
            if "strict" in schema_section:
                format_config["strict"] = schema_section["strict"]
            if "description" in schema_section and schema_section["description"] is not None:
                format_config["description"] = schema_section["description"]
            return format_config

        if format_type in {"json_object", "text"}:
            return {"type": format_type}

        # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}})
        # by wrapping them in the expected json_schema envelope.
        # Detect by checking for JSON Schema primitive types or known schema keywords.
        json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"}
        json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"}
        if format_type in json_schema_primitive_types or (
            format_type is None and any(k in response_format for k in json_schema_keywords)
        ):
            schema = dict(response_format)
            if schema.get("type") == "object" and "additionalProperties" not in schema:
                schema["additionalProperties"] = False
            # Pop title from schema since OpenAI strict mode rejects unknown keys;
            # use it as the schema name in the envelope instead.
            name = str(schema.pop("title", None) or "response")
            return {
                "type": "json_schema",
                "name": name,
                "schema": schema,
                "strict": True,
            }

        raise ChatClientInvalidRequestException("Unsupported response_format provided for Responses client.")

    def _get_conversation_id(
        self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
    ) -> str | None:
        """Get the conversation ID from the response if store is True."""
        if store is False:
            return None
        # If conversation ID exists, it means that we operate with conversation
        # so we use conversation ID as input and output.
        if response.conversation and response.conversation.id:
            return response.conversation.id
        # If conversation ID doesn't exist, we operate with responses
        # so we use response ID as input and output.
        return response.id

    # region Prep methods

    def _prepare_tools_for_openai(
        self,
        tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
    ) -> list[Any]:
        """Prepare tools for the OpenAI Responses API.

        Converts FunctionTool to Responses API format. Shell-enabled FunctionTools
        with explicit shell environment metadata are mapped to OpenAI shell tools.
        All other tools pass through unchanged.

        Args:
            tools: A single tool or sequence of tools to prepare.

        Returns:
            List of tool parameters ready for the OpenAI API.
        """
        tools_list = normalize_tools(tools)
        if not tools_list:
            return []
        response_tools: list[Any] = []
        for tool_item in tools_list:
            if isinstance(tool_item, FunctionTool) and tool_item.kind == SHELL_TOOL_KIND_VALUE:
                shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY)
                response_tools.append(
                    FunctionShellToolParam(
                        type="shell",
                        environment=shell_env,
                    )
                )
                continue
            if isinstance(tool_item, FunctionTool):
                params = tool_item.parameters()
                params["additionalProperties"] = False
                response_tools.append(
                    FunctionToolParam(
                        name=tool_item.name,
                        parameters=params,
                        strict=False,
                        type="function",
                        description=tool_item.description,
                    )
                )
            else:
                # Pass through all other tools (dicts, SDK types) unchanged
                response_tools.append(tool_item)
        return response_tools

    def _get_local_shell_tool_name(
        self,
        tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
    ) -> str | None:
        """Return the name of the configured local shell tool function, if any."""
        for tool_item in normalize_tools(tools):
            if not isinstance(tool_item, FunctionTool):
                continue
            if tool_item.kind != SHELL_TOOL_KIND_VALUE:
                continue
            shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY)
            if isinstance(shell_env, Mapping) and shell_env.get("type") == "local":  # type: ignore[typeddict-item]
                return tool_item.name
        return None

    # region Hosted Tool Factory Methods

    @staticmethod
    def get_code_interpreter_tool(
        *,
        file_ids: list[str] | None = None,
        container: Literal["auto"] | CodeInterpreterContainerCodeInterpreterToolAuto = "auto",
    ) -> Any:
        """Create a code interpreter tool configuration for the Responses API.

        Keyword Args:
            file_ids: List of file IDs to make available to the code interpreter.
            container: Container configuration. Use "auto" for automatic container management,
                or provide a TypedDict with custom container settings.

        Returns:
            A CodeInterpreter tool parameter ready to pass to ChatAgent.

        Examples:
            .. code-block:: python

                from agent_framework.openai import OpenAIChatClient

                # Basic code interpreter
                tool = OpenAIChatClient.get_code_interpreter_tool()

                # With file access
                tool = OpenAIChatClient.get_code_interpreter_tool(file_ids=["file-abc123"])

                # Use with agent
                agent = ChatAgent(client, tools=[tool])
        """
        container_config: CodeInterpreterContainerCodeInterpreterToolAuto = (
            container if isinstance(container, dict) else {"type": "auto"}
        )

        if file_ids:
            container_config["file_ids"] = file_ids

        return CodeInterpreter(type="code_interpreter", container=container_config)

    @staticmethod
    def get_web_search_tool(
        *,
        user_location: dict[str, str] | None = None,
        search_context_size: Literal["low", "medium", "high"] | None = None,
        filters: dict[str, Any] | None = None,
    ) -> Any:
        """Create a web search tool configuration for the Responses API.

        Keyword Args:
            user_location: Location context for search results. Dict with keys like
                "city", "country", "region", "timezone".
            search_context_size: Amount of context to include from search results.
                One of "low", "medium", or "high".
            filters: Additional search filters.

        Returns:
            A WebSearchToolParam dict ready to pass to ChatAgent.

        Examples:
            .. code-block:: python

                from agent_framework.openai import OpenAIChatClient

                # Basic web search
                tool = OpenAIChatClient.get_web_search_tool()

                # With location context
                tool = OpenAIChatClient.get_web_search_tool(
                    user_location={"city": "Seattle", "country": "US"},
                    search_context_size="medium",
                )

                agent = ChatAgent(client, tools=[tool])
        """
        web_search_tool = WebSearchToolParam(type="web_search")

        if user_location:
            web_search_tool["user_location"] = {
                "type": "approximate",
                "city": user_location.get("city"),
                "country": user_location.get("country"),
                "region": user_location.get("region"),
                "timezone": user_location.get("timezone"),
            }

        if search_context_size:
            web_search_tool["search_context_size"] = search_context_size

        if filters:
            web_search_tool["filters"] = filters  # type: ignore[typeddict-item]

        return web_search_tool

    @staticmethod
    def get_image_generation_tool(
        *,
        size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None,
        output_format: Literal["png", "jpeg", "webp"] | None = None,
        model: Literal["gpt-image-1", "gpt-image-1-mini"] | str | None = None,
        quality: Literal["low", "medium", "high", "auto"] | None = None,
        partial_images: int | None = None,
        background: Literal["transparent", "opaque", "auto"] | None = None,
        moderation: Literal["auto", "low"] | None = None,
        output_compression: int | None = None,
    ) -> Any:
        """Create an image generation tool configuration for the Responses API.

        Keyword Args:
            size: Image dimensions. One of "1024x1024", "1024x1536", "1536x1024", or "auto".
            output_format: Output image format. One of "png", "jpeg", or "webp".
            model: Model to use for image generation. One of "gpt-image-1" or "gpt-image-1-mini".
            quality: Image quality level. One of "low", "medium", "high", or "auto".
            partial_images: Number of partial images to stream during generation.
            background: Background type. One of "transparent", "opaque", or "auto".
            moderation: Moderation level. One of "auto" or "low".
            output_compression: Compression level for output (0-100).

        Returns:
            An ImageGeneration tool parameter dict ready to pass to ChatAgent.

        Examples:
            .. code-block:: python

                from agent_framework.openai import OpenAIChatClient

                # Basic image generation
                tool = OpenAIChatClient.get_image_generation_tool()

                # High quality large image
                tool = OpenAIChatClient.get_image_generation_tool(
                    size="1536x1024",
                    quality="high",
                    output_format="png",
                )

                agent = ChatAgent(client, tools=[tool])
        """
        tool: ImageGeneration = {"type": "image_generation"}

        if size:
            tool["size"] = size
        if output_format:
            tool["output_format"] = output_format
        if model:
            tool["model"] = model
        if quality:
            tool["quality"] = quality
        if partial_images is not None:
            tool["partial_images"] = partial_images
        if background:
            tool["background"] = background
        if moderation:
            tool["moderation"] = moderation
        if output_compression is not None:
            tool["output_compression"] = output_compression

        return tool

    @staticmethod
    def get_shell_tool(
        *,
        func: Callable[..., Any] | FunctionTool | None = None,
        environment: Literal["auto"] | dict[str, Any] | None = "auto",
        name: str | None = None,
        description: str | None = None,
        approval_mode: Literal["always_require", "never_require"] | None = None,
    ) -> Any:
        """Create a shell tool for the Responses API.

        - When ``func`` is ``None`` (default), returns an OpenAI hosted shell
          tool declaration.
        - When ``func`` is provided, returns a local FunctionTool that is
          declared to OpenAI as a local shell tool and executed via the function
          invocation layer.

        Keyword Args:
            func: Optional local shell function or ``FunctionTool``.
            environment: Container environment configuration.
                Used only when ``func`` is ``None``.
                Use ``"auto"`` (default) for managed containers, or provide a
                dict with explicit hosted container settings.
            name: Optional local tool name when ``func`` is provided.
            description: Optional local tool description when ``func`` is provided.
            approval_mode: Optional local tool approval mode.

        Returns:
            A hosted shell declaration or a local shell FunctionTool.

        Examples:
            .. code-block:: python

                from agent_framework.openai import OpenAIChatClient

                # Hosted shell (OpenAI container)
                tool = OpenAIChatClient.get_shell_tool()

                # Hosted shell with custom environment
                tool = OpenAIChatClient.get_shell_tool(environment={"type": "container_auto", "file_ids": ["file-abc"]})

                # Local shell execution
                tool = OpenAIChatClient.get_shell_tool(
                    func=my_shell_func,
                )
        """
        if func is None:
            env_config: dict[str, Any] = (
                dict(environment) if isinstance(environment, dict) else {"type": "container_auto"}
            )
            if env_config.get("type") == "local":
                raise ValueError("Local shell requires func. Provide func for local execution.")
            return FunctionShellToolParam(type="shell", environment=env_config)  # type: ignore[typeddict-item]

        if isinstance(environment, dict):
            raise ValueError("When func is provided, environment config is not supported.")
        local_env = {"type": "local"}

        base_tool: FunctionTool
        if isinstance(func, FunctionTool):
            base_tool = func
            if name is not None:
                base_tool.name = name
            if description is not None:
                base_tool.description = description
            if approval_mode is not None:
                base_tool.approval_mode = approval_mode
        else:
            base_tool = tool(
                func=func,
                name=name,
                description=description,
                approval_mode=approval_mode,
            )

        if base_tool.func is None:
            raise ValueError("Shell tool requires an executable function.")

        additional_properties = dict(base_tool.additional_properties or {})
        additional_properties[OPENAI_SHELL_ENVIRONMENT_KEY] = local_env
        base_tool.additional_properties = additional_properties
        base_tool.kind = SHELL_TOOL_KIND_VALUE
        return base_tool

    @staticmethod
    def get_mcp_tool(
        *,
        name: str,
        url: str,
        description: str | None = None,
        approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None,
        allowed_tools: list[str] | None = None,
        headers: dict[str, str] | None = None,
    ) -> Any:
        """Create a hosted MCP (Model Context Protocol) tool configuration for the Responses API.

        This configures an MCP server that will be called by OpenAI's service.
        The tools from this MCP server are executed remotely by OpenAI,
        not locally by your application.

        Note:
            For local MCP execution where your application calls the MCP server
            directly, use the MCP client tools instead of this method.

        Keyword Args:
            name: A label/name for the MCP server.
            url: The URL of the MCP server.
            description: A description of what the MCP server provides.
            approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools,
                or provide a dict with "always_require_approval" and/or "never_require_approval"
                keys mapping to lists of tool names.
            allowed_tools: List of tool names that are allowed to be used from this MCP server.
            headers: HTTP headers to include in requests to the MCP server.

        Returns:
            An Mcp tool parameter dict ready to pass to ChatAgent.

        Examples:
            .. code-block:: python

                from agent_framework.openai import OpenAIChatClient

                # Basic MCP tool
                tool = OpenAIChatClient.get_mcp_tool(
                    name="my_mcp",
                    url="https://mcp.example.com",
                )

                # With approval settings
                tool = OpenAIChatClient.get_mcp_tool(
                    name="github_mcp",
                    url="https://mcp.github.com",
                    description="GitHub MCP server",
                    approval_mode="always_require",
                    headers={"Authorization": "Bearer token"},
                )

                # With specific tool approvals
                tool = OpenAIChatClient.get_mcp_tool(
                    name="tools_mcp",
                    url="https://tools.example.com",
                    approval_mode={
                        "always_require_approval": ["dangerous_tool"],
                        "never_require_approval": ["safe_tool"],
                    },
                )

                agent = ChatAgent(client, tools=[tool])
        """
        mcp: Mcp = {
            "type": "mcp",
            "server_label": name.replace(" ", "_"),
            "server_url": url,
        }

        if description:
            mcp["server_description"] = description

        if headers:
            mcp["headers"] = headers

        if allowed_tools:
            mcp["allowed_tools"] = allowed_tools

        if approval_mode:
            if isinstance(approval_mode, str):
                mcp["require_approval"] = "always" if approval_mode == "always_require" else "never"
            else:
                if always_require := approval_mode.get("always_require_approval"):
                    mcp["require_approval"] = {"always": {"tool_names": always_require}}
                if never_require := approval_mode.get("never_require_approval"):
                    mcp["require_approval"] = {"never": {"tool_names": never_require}}

        return mcp

    @staticmethod
    def get_file_search_tool(
        *,
        vector_store_ids: list[str],
        max_num_results: int | None = None,
    ) -> Any:
        """Create a file search tool configuration for the Responses API.

        Keyword Args:
            vector_store_ids: List of vector store IDs to search within.
            max_num_results: Maximum number of results to return. Defaults to 50 if not specified.

        Returns:
            A FileSearchToolParam dict ready to pass to ChatAgent.

        Examples:
            .. code-block:: python

                from agent_framework.openai import OpenAIChatClient

                # Basic file search
                tool = OpenAIChatClient.get_file_search_tool(
                    vector_store_ids=["vs_abc123"],
                )

                # With result limit
                tool = OpenAIChatClient.get_file_search_tool(
                    vector_store_ids=["vs_abc123", "vs_def456"],
                    max_num_results=10,
                )

                agent = ChatAgent(client, tools=[tool])
        """
        tool = FileSearchToolParam(
            type="file_search",
            vector_store_ids=vector_store_ids,
        )

        if max_num_results is not None:
            tool["max_num_results"] = max_num_results

        return tool

    # endregion

    async def _prepare_options(
        self,
        messages: Sequence[Message],
        options: Mapping[str, Any],
    ) -> dict[str, Any]:
        """Take options dict and create the specific options for Responses API."""
        # Exclude keys that are not supported or handled separately
        exclude_keys = {
            "type",
            "presence_penalty",  # not supported
            "frequency_penalty",  # not supported
            "logit_bias",  # not supported
            "seed",  # not supported
            "stop",  # not supported
            "instructions",  # already added as system message
            "response_format",  # handled separately
            "conversation_id",  # handled separately
            "tool_choice",  # handled separately
            "continuation_token",  # handled separately in _inner_get_response
        }
        run_options: dict[str, Any] = {k: v for k, v in options.items() if k not in exclude_keys and v is not None}

        # messages
        # Handle instructions by prepending to messages as system message
        # Only prepend instructions for the first turn (when no conversation/response ID exists)
        conversation_id = options.get("conversation_id")
        if (instructions := options.get("instructions")) and not conversation_id:
            # First turn: prepend instructions as system message
            messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
        # Continuation turn: instructions already exist in conversation context, skip prepending
        request_uses_service_side_storage = False
        for key in ("conversation_id", "previous_response_id", "conversation"):
            value = options.get(key)
            if isinstance(value, str) and value:
                request_uses_service_side_storage = True
                break
        request_input = self._prepare_messages_for_openai(
            messages,
            request_uses_service_side_storage=request_uses_service_side_storage,
        )
        if not request_input:
            raise ChatClientInvalidRequestException("Messages are required for chat completions")
        conversation_id = options.get("conversation_id")
        run_options["input"] = request_input

        # model id
        self._check_model_presence(run_options)

        # translations between options and Responses API
        translations = {
            "allow_multiple_tool_calls": "parallel_tool_calls",
            "conversation_id": "previous_response_id",
            "max_tokens": "max_output_tokens",
        }
        for old_key, new_key in translations.items():
            if old_key in run_options and old_key != new_key:
                run_options[new_key] = run_options.pop(old_key)

        # Handle different conversation ID formats
        if conversation_id := options.get("conversation_id"):
            if conversation_id.startswith("resp_"):
                # For response IDs, set previous_response_id and remove conversation property
                run_options["previous_response_id"] = conversation_id
            elif conversation_id.startswith("conv_"):
                # For conversation IDs, set conversation and remove previous_response_id property
                run_options["conversation"] = conversation_id
            else:
                # If the format is unrecognized, default to previous_response_id
                run_options["previous_response_id"] = conversation_id

        # tools
        if tools := self._prepare_tools_for_openai(options.get("tools")):
            run_options["tools"] = tools
            # tool_choice: convert ToolMode to appropriate format
            if tool_choice := options.get("tool_choice"):
                tool_mode = validate_tool_mode(tool_choice)
                if tool_mode is not None:
                    if (mode := tool_mode.get("mode")) == "required" and (
                        func_name := tool_mode.get("required_function_name")
                    ) is not None:
                        run_options["tool_choice"] = {
                            "type": "function",
                            "name": func_name,
                        }
                    elif mode in ("auto", "required") and (allowed := tool_mode.get("allowed_tools")) is not None:
                        run_options["tool_choice"] = {
                            "type": "allowed_tools",
                            "mode": mode,
                            "tools": [{"type": "function", "name": name} for name in allowed],
                        }
                    else:
                        run_options["tool_choice"] = mode
        else:
            run_options.pop("parallel_tool_calls", None)
            run_options.pop("tool_choice", None)

        # response format and text config
        response_format = options.get("response_format")
        text_config = run_options.pop("text", None)
        response_format, text_config = self._prepare_response_and_text_format(
            response_format=response_format, text_config=text_config
        )
        # The Responses API nests verbosity under ``text.verbosity``; surface it as a
        # top-level option for parity with ``reasoning`` and translate here.
        if (verbosity := run_options.pop("verbosity", None)) is not None:
            text_config = dict(text_config) if text_config else {}
            text_config["verbosity"] = verbosity
        if text_config:
            run_options["text"] = text_config
        if response_format:
            run_options["text_format"] = response_format

        return run_options

    def _check_model_presence(self, options: dict[str, Any]) -> None:
        """Check if the 'model' param is present, and if not raise a Error.

        Subclasses can override this when they populate the model through a different option field.
        """
        if not options.get("model"):
            if not self.model:
                raise ValueError("model must be a non-empty string")
            options["model"] = self.model

    def _prepare_messages_for_openai(
        self,
        chat_messages: Sequence[Message],
        *,
        request_uses_service_side_storage: bool = True,
    ) -> list[dict[str, Any]]:
        """Prepare the chat messages for a request.

        Allowing customization of the key names for role/author, and optionally overriding the role.

        "tool" messages need to be formatted different than system/user/assistant messages:
            They require a "tool_call_id" and (function) "name" key, and the "metadata" key should
            be removed. The "encoding" key should also be removed.

        Override this method to customize the formatting of the chat history for a request.

        Args:
            chat_messages: The chat history to prepare.
            request_uses_service_side_storage: Whether this request continues a service-managed
                response/conversation and can safely reference service-scoped response items.

        Returns:
            The prepared chat messages for a request.
        """
        drops_reasoning_without_storage = not request_uses_service_side_storage and any(
            content.type == "text_reasoning" for message in chat_messages for content in message.contents
        )
        drop_mcp_call_ids: set[str] = set()
        if drops_reasoning_without_storage:
            drop_mcp_call_ids = _mcp_call_ids_paired_with_reasoning(chat_messages)

        list_of_list = [
            self._prepare_message_for_openai(
                message,
                request_uses_service_side_storage=request_uses_service_side_storage,
                drop_mcp_call_ids=drop_mcp_call_ids,
            )
            for message in chat_messages
        ]
        # Flatten the list of lists into a single list
        flat = list(chain.from_iterable(list_of_list))
        # Coalesce hosted-MCP result markers onto matching mcp_call input
        # items (drop unmatched). See `_AF_MCP_PENDING_OUTPUT_KEY`.
        return self._coalesce_pending_mcp_results(flat)

    def _prepare_message_for_openai(
        self,
        message: Message,
        *,
        request_uses_service_side_storage: bool = True,
        drop_mcp_call_ids: set[str] | None = None,
    ) -> list[dict[str, Any]]:
        """Prepare a chat message for the OpenAI Responses API format."""
        all_messages: list[dict[str, Any]] = []
        args: dict[str, Any] = {
            "type": "message",
            "role": message.role,
        }
        additional_properties = message.additional_properties
        replays_local_storage = "_attribution" in additional_properties
        # Server-issued response item identities (function_call fc_*, reasoning rs_*, approval IDs,
        # local-shell-call IDs) must not be re-sent inline when the request carries
        # previous_response_id / conversation_id / conversation: the server already has them via
        # the prior response and rejects duplicates with "Duplicate item found with id ...".
        # function_result keeps its call_id and the server pairs it to the prior function_call via
        # that key. See microsoft/agent-framework#3295. The strip is gated on the request-level
        # flag, not a message-level one: HistoryProvider-attributed messages
        # (replays_local_storage) still need stripping when the request also carries a continuation
        # marker, since the server-stored items would otherwise duplicate the inline ones. Without
        # storage, standalone reasoning items are invalid per the API ("reasoning was provided
        # without its required following item"), so the reasoning branch always drops. When that
        # happens, `_prepare_messages_for_openai` also drops the paired hosted-MCP IDs across
        # message boundaries rather than replaying bare MCP items.
        drop_mcp_call_ids = drop_mcp_call_ids or set()
        for content in message.contents:
            match content.type:
                case "text_reasoning":
                    continue
                case "function_result":
                    if request_uses_service_side_storage:
                        props = content.additional_properties or {}
                        # Local-shell variant serializes as `local_shell_call` carrying a server-issued id;
                        # plain function_call_output pairs by call_id and is safe under storage.
                        if props.get(
                            OPENAI_SHELL_OUTPUT_TYPE_KEY
                        ) == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL and props.get(
                            OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY
                        ):
                            continue
                    new_args: dict[str, Any] = {}
                    new_args.update(
                        self._prepare_content_for_openai(
                            message.role,
                            content,
                            replays_local_storage=replays_local_storage,
                        )
                    )
                    if new_args:
                        all_messages.append(new_args)
                case "function_call":
                    if request_uses_service_side_storage:
                        continue
                    function_call = self._prepare_content_for_openai(
                        message.role,
                        content,
                        replays_local_storage=replays_local_storage,
                    )
                    if function_call:
                        all_messages.append(function_call)
                case "function_approval_response" | "function_approval_request":
                    if request_uses_service_side_storage:
                        continue
                    prepared = self._prepare_content_for_openai(
                        message.role,
                        content,
                        replays_local_storage=replays_local_storage,
                    )
                    if prepared:
                        all_messages.append(prepared)
                case "mcp_server_tool_call" | "mcp_server_tool_result":
                    # Hosted MCP call/result contents serialize as a single
                    # top-level mcp_call input item; the result side emits an
                    # internal marker that `_prepare_messages_for_openai`
                    # coalesces onto the matching call (or drops if unmatched).
                    # The mcp_call item carries the model-emitted call_id as its
                    # server-side `id`, so under continuation it would duplicate
                    # the prior response's items (#3295). Drop the call here; the
                    # orphan result is dropped by the coalesce step that follows.
                    #
                    # Without storage, a reasoning + hosted-MCP pair cannot be replayed
                    # partially: reasoning is stripped above, and a bare mcp_call is rejected.
                    if request_uses_service_side_storage or content.call_id in drop_mcp_call_ids:
                        continue
                    prepared_mcp = self._prepare_content_for_openai(
                        message.role,
                        content,
                        replays_local_storage=replays_local_storage,
                    )
                    if prepared_mcp:
                        all_messages.append(prepared_mcp)
                case _:
                    prepared_content = self._prepare_content_for_openai(
                        message.role,
                        content,
                        replays_local_storage=replays_local_storage,
                    )
                    if prepared_content:
                        if "content" not in args:
                            args["content"] = []
                        args["content"].append(prepared_content)  # type: ignore[reportUnknownMemberType]
        if "content" in args or "tool_calls" in args:
            all_messages.append(args)
        return all_messages

    def _prepare_content_for_openai(
        self,
        role: Role | str,
        content: Content,
        *,
        replays_local_storage: bool = False,
    ) -> dict[str, Any]:
        """Prepare content for the OpenAI Responses API format."""
        role = Role(role)
        match content.type:
            case "text":
                if role == "assistant":
                    # Assistant history is represented as output text items; Azure validation
                    # requires `annotations` to be present for this type.
                    return {
                        "type": "output_text",
                        "text": content.text,
                        "annotations": _annotations_to_output_text(getattr(content, "annotations", None)),
                    }
                return {
                    "type": "input_text",
                    "text": content.text,
                }
            case "text_reasoning":
                ret: dict[str, Any] = {"type": "reasoning", "summary": []}
                if content.id:
                    ret["id"] = content.id
                props: dict[str, Any] | None = getattr(content, "additional_properties", None)
                if props:
                    if status := props.get("status"):
                        ret["status"] = status
                    if reasoning_text := props.get("reasoning_text"):
                        ret["content"] = [{"type": "reasoning_text", "text": reasoning_text}]
                    if encrypted_content := props.get("encrypted_content"):
                        ret["encrypted_content"] = encrypted_content
                if content.text:
                    ret["summary"].append({"type": "summary_text", "text": content.text})
                return ret
            case "data" | "uri":
                if content.has_top_level_media_type("image"):
                    result: dict[str, Any] = {
                        "type": "input_image",
                        "image_url": content.uri,
                        "detail": content.additional_properties.get("detail", "auto")
                        if content.additional_properties
                        else "auto",
                    }
                    file_id = content.additional_properties.get("file_id") if content.additional_properties else None
                    if file_id is not None:
                        result["file_id"] = file_id
                    return result
                if content.has_top_level_media_type("audio"):
                    if content.media_type and "wav" in content.media_type:
                        format = "wav"
                    elif content.media_type and "mp3" in content.media_type:
                        format = "mp3"
                    else:
                        logger.warning("Unsupported audio media type: %s", content.media_type)
                        return {}
                    return {
                        "type": "input_audio",
                        "input_audio": {
                            "data": content.uri,
                            "format": format,
                        },
                    }
                if content.has_top_level_media_type("application"):
                    filename = getattr(content, "filename", None) or (
                        content.additional_properties.get("filename")
                        if hasattr(content, "additional_properties") and content.additional_properties
                        else None
                    )
                    file_obj = {
                        "type": "input_file",
                        "file_data": content.uri,
                    }
                    if filename:
                        file_obj["filename"] = filename
                    return file_obj
                return {}
            case "function_call":
                if not content.call_id:
                    logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
                    return {}
                fc_id = content.call_id
                if not replays_local_storage and content.additional_properties:
                    live_fc_id = content.additional_properties.get("fc_id")
                    if isinstance(live_fc_id, str) and live_fc_id:
                        fc_id = live_fc_id
                # OpenAI Responses API requires IDs to start with `fc_`
                if not fc_id.startswith("fc_"):
                    fc_id = f"fc_{fc_id}"

                function_call_obj = {
                    "call_id": content.call_id,
                    "id": fc_id,
                    "type": "function_call",
                    "name": content.name,
                    "arguments": content.arguments,
                }
                if status := content.additional_properties.get("status"):
                    function_call_obj["status"] = status
                return function_call_obj
            case "function_result":
                shell_output_type = (
                    content.additional_properties.get(OPENAI_SHELL_OUTPUT_TYPE_KEY)
                    if content.additional_properties
                    else None
                )
                if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL:
                    return {
                        "call_id": content.call_id,
                        "type": OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL,
                        "output": self._to_shell_call_output_payload(content),
                    }
                local_shell_call_item_id = (
                    content.additional_properties.get(OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY)
                    if content.additional_properties
                    else None
                )
                if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL and local_shell_call_item_id:
                    return {
                        "id": local_shell_call_item_id,
                        "type": OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL,
                        "output": self._to_local_shell_output_payload(content),
                    }
                # call_id for the result needs to be the same as the call_id for the function call
                output: str | list[dict[str, Any]] = content.result or ""
                if (
                    self.SUPPORTS_RICH_FUNCTION_OUTPUT
                    and content.items
                    and any(item.type in ("data", "uri") for item in content.items)
                ):
                    output_parts: list[dict[str, Any]] = []
                    for item in content.items:
                        if item.type == "text":
                            output_parts.append({"type": "input_text", "text": item.text or ""})
                        else:
                            part = self._prepare_content_for_openai("user", item)
                            if part:
                                output_parts.append(part)
                    if output_parts:
                        output = output_parts
                return {
                    "call_id": content.call_id,
                    "type": "function_call_output",
                    "output": output,
                }
            case "function_approval_request":
                return {
                    "type": "mcp_approval_request",
                    "id": content.id,
                    "arguments": content.function_call.arguments,  # type: ignore[union-attr]
                    "name": content.function_call.name,  # type: ignore[union-attr]
                    "server_label": content.function_call.additional_properties.get("server_label")  # type: ignore[union-attr]
                    if content.function_call.additional_properties  # type: ignore[union-attr]
                    else None,
                }
            case "function_approval_response":
                return {
                    "type": "mcp_approval_response",
                    "approval_request_id": content.id,
                    "approve": content.approved,
                }
            case "mcp_server_tool_call":
                if not content.call_id:
                    return {}
                return {
                    "type": "mcp_call",
                    "id": content.call_id,
                    "server_label": content.server_name or "",
                    "name": content.tool_name or "",
                    "arguments": self._stringify_mcp_arguments(content.arguments),
                }
            case "mcp_server_tool_result":
                if not content.call_id:
                    return {}
                return {
                    _AF_MCP_PENDING_OUTPUT_KEY: True,
                    "call_id": content.call_id,
                    "output": self._stringify_mcp_output(content.output),
                }
            case "hosted_file":
                # `input_file` is an input-only content type in the Responses API and is rejected
                # inside an assistant message. Hosted-file content on an assistant message
                # represents a citation produced by a hosted tool (e.g., file_search) and cannot be
                # meaningfully replayed as input — drop it. The accompanying text annotations carry
                # the citation context for round-tripping.
                if role == "assistant":
                    return {}
                return {
                    "type": "input_file",
                    "file_id": content.file_id,
                }
            case _:  # should catch UsageDetails and ErrorContent and HostedVectorStoreContent
                logger.debug("Unsupported content type passed (type: %s)", content.type)
                return {}

    @staticmethod
    def _to_local_shell_output_payload(content: Content) -> str:
        """Convert function tool output to the local shell JSON payload format."""
        payload: dict[str, Any]
        if isinstance(content.result, Mapping):
            payload = dict(content.result)  # type: ignore[assignment]
        else:
            payload = {
                "stdout": "" if content.result is None else str(content.result),
            }
        if content.exception is not None and "stderr" not in payload:
            payload["stderr"] = str(content.exception)
        if "exit_code" not in payload:
            payload["exit_code"] = 1 if content.exception else 0
        return json.dumps(payload, ensure_ascii=False)

    @staticmethod
    def _to_shell_call_output_payload(content: Content) -> list[dict[str, Any]]:
        """Convert function tool output to shell_call_output payload format."""
        payload: dict[str, Any]
        if isinstance(content.result, Mapping):
            payload = dict(content.result)  # type: ignore[assignment]
        else:
            payload = {
                "stdout": "" if content.result is None else str(content.result),
            }
        if content.exception is not None and "stderr" not in payload:
            payload["stderr"] = str(content.exception)

        # Pass through native payload shape when tool already returns shell output entries.
        direct_output = payload.get("output")
        if isinstance(direct_output, list) and all(isinstance(item, Mapping) for item in direct_output):  # type: ignore[reportUnknownMemberType]
            return [dict(item) for item in direct_output]  # type: ignore[reportUnknownMemberType]

        stdout = str(payload.get("stdout", ""))
        stderr = str(payload.get("stderr", ""))
        timed_out = bool(payload.get("timed_out", False))
        if timed_out:
            outcome: dict[str, Any] = {"type": "timeout"}
        else:
            exit_code_raw = payload.get("exit_code")
            try:
                exit_code = int(exit_code_raw) if exit_code_raw is not None else (1 if content.exception else 0)
            except (TypeError, ValueError):
                exit_code = 1 if content.exception else 0
            outcome = {"type": "exit", "exit_code": exit_code}
        return [
            {
                "stdout": stdout,
                "stderr": stderr,
                "outcome": outcome,
            }
        ]

    @staticmethod
    def _join_shell_commands(commands: Sequence[str]) -> str:
        """Join shell commands into a single executable command string."""
        return "\n".join(command for command in commands if command).strip()

    def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) -> list[Content]:
        """Convert a shell output item into framework ``Content`` objects.

        Handles ``shell_call``, ``local_shell_call``, and ``shell_call_output`` items.
        Used by both the non-streaming parser and the streaming
        ``response.output_item.done`` handler, where the item carries the fully
        populated ``action`` (commands are not available on the earlier
        ``response.output_item.added`` event, and there are no shell-specific
        streaming delta events).
        """
        contents: list[Content] = []
        item_type = getattr(item, "type", None)
        if item_type == "shell_call":
            shell_call_id = getattr(item, "call_id", None) or ""
            shell_commands: list[str] = []
            shell_timeout_ms: int | None = None
            shell_max_output: int | None = None
            if action := getattr(item, "action", None):
                shell_commands = list(getattr(action, "commands", []) or [])
                shell_timeout_ms = getattr(action, "timeout_ms", None)
                shell_max_output = getattr(action, "max_output_length", None)
            if local_shell_tool_name:
                command_text = self._join_shell_commands(shell_commands)
                contents.append(
                    Content.from_function_call(
                        call_id=shell_call_id,
                        name=local_shell_tool_name,
                        arguments=json.dumps({"command": command_text}),
                        additional_properties={
                            OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL,
                            OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: shell_commands,
                        },
                        raw_representation=item,
                    )
                )
            else:
                contents.append(
                    Content.from_shell_tool_call(
                        call_id=shell_call_id,
                        commands=shell_commands,
                        timeout_ms=shell_timeout_ms,
                        max_output_length=shell_max_output,
                        status=getattr(item, "status", None),
                        raw_representation=item,
                    )
                )
        elif item_type == "local_shell_call":
            local_call_id = getattr(item, "call_id", None) or ""
            local_command_parts = list(getattr(getattr(item, "action", None), "command", []) or [])
            local_command = shlex.join(local_command_parts) if local_command_parts else ""
            if local_shell_tool_name:
                contents.append(
                    Content.from_function_call(
                        call_id=local_call_id,
                        name=local_shell_tool_name,
                        arguments=json.dumps({"command": local_command}),
                        additional_properties={
                            OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL,
                            OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: getattr(item, "id", None),
                            OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: local_command_parts,
                        },
                        raw_representation=item,
                    )
                )
            else:
                contents.append(
                    Content.from_shell_tool_call(
                        call_id=local_call_id,
                        commands=[local_command] if local_command else [],
                        timeout_ms=getattr(getattr(item, "action", None), "timeout_ms", None),
                        status=getattr(item, "status", None),
                        raw_representation=item,
                    )
                )
        elif item_type == "shell_call_output":
            shell_output_call_id = getattr(item, "call_id", None) or ""
            shell_outputs: list[Content] = []
            for shell_out in getattr(item, "output", []) or []:
                s_exit_code: int | None = None
                s_timed_out: bool | None = None
                if outcome := getattr(shell_out, "outcome", None):
                    if getattr(outcome, "type", None) == "exit":
                        s_exit_code = getattr(outcome, "exit_code", None)
                        s_timed_out = False
                    elif getattr(outcome, "type", None) == "timeout":
                        s_timed_out = True
                shell_outputs.append(
                    Content.from_shell_command_output(
                        stdout=getattr(shell_out, "stdout", None),
                        stderr=getattr(shell_out, "stderr", None),
                        exit_code=s_exit_code,
                        timed_out=s_timed_out,
                        raw_representation=shell_out,
                    )
                )
            contents.append(
                Content.from_shell_tool_result(
                    call_id=shell_output_call_id,
                    outputs=shell_outputs,
                    max_output_length=getattr(item, "max_output_length", None),
                    raw_representation=item,
                )
            )
        return contents

    @staticmethod
    def _stringify_mcp_arguments(arguments: Any) -> str:
        """Render hosted-MCP tool-call arguments as a JSON string for the Responses API."""
        if arguments is None:
            return ""
        if isinstance(arguments, str):
            return arguments
        try:
            return json.dumps(arguments)
        except (TypeError, ValueError):
            return str(arguments)

    @staticmethod
    def _stringify_mcp_output(output: Any) -> str:
        """Render a hosted-MCP tool-call result into the string `mcp_call.output` field.

        Accepts a string, a list of text-bearing Content objects (the form
        the chat client produces when parsing an `mcp_call` Responses item),
        or any other value. List entries that are dicts with the canonical
        MCP text-content shape (`{"text": "..."}`) are unwrapped to their
        text. Anything else falls back to JSON encoding rather than Python
        `repr`, so the wire payload stays parseable for downstream callers.
        """
        if output is None:
            return ""
        if isinstance(output, str):
            return output
        if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
            # cast is for pyright (reportUnknownVariableType); mypy considers
            # it redundant after the isinstance narrowing.
            entries = cast(Sequence[Any], output)
            parts: list[str] = []
            for entry in entries:
                if isinstance(entry, str):
                    parts.append(entry)
                    continue
                text = getattr(entry, "text", None)
                if isinstance(text, str):
                    parts.append(text)
                    continue
                if isinstance(entry, Mapping):
                    mapping_text = cast(Any, entry).get("text")
                    if isinstance(mapping_text, str):
                        parts.append(mapping_text)
                        continue
                parts.append(json.dumps(entry, default=str))
            return "".join(parts)
        return json.dumps(output, default=str)

    @staticmethod
    def _coalesce_pending_mcp_results(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Merge pending hosted-MCP result markers onto matching mcp_call input items.

        See `_AF_MCP_PENDING_OUTPUT_KEY`. The Responses API expects a single
        `mcp_call` input item carrying both `arguments` and `output`, so a
        result Content cannot be its own input item. Any unmatched markers
        are dropped (debug-logged); surfacing them as standalone items
        would produce the orphan `function_call_output` / `mcp_call_output`
        the API rejects.
        """
        out: list[dict[str, Any]] = []
        for item in items:
            if item.get(_AF_MCP_PENDING_OUTPUT_KEY):
                target_call_id = item.get("call_id")
                target = next(
                    (
                        existing
                        for existing in reversed(out)
                        if existing.get("type") == "mcp_call" and existing.get("id") == target_call_id
                    ),
                    None,
                )
                if target is not None:
                    if target.get("output") is None:
                        target["output"] = item.get("output")
                else:
                    logger.debug(
                        "Dropping orphan mcp_server_tool_result for call_id=%s; "
                        "no matching mcp_call appeared in input.",
                        target_call_id,
                    )
                continue
            out.append(item)
        return out

    @staticmethod
    def _serialize_provider_payload(value: Any) -> Any:
        """Convert OpenAI SDK objects into JSON-serializable Python values."""
        if isinstance(value, BaseModel):
            return value.model_dump(mode="json", exclude_none=True)
        if isinstance(value, Mapping):
            return {str(key): RawOpenAIChatClient._serialize_provider_payload(item) for key, item in value.items()}  # type: ignore[reportUnknownVariableType]
        if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
            return [RawOpenAIChatClient._serialize_provider_payload(item) for item in value]  # type: ignore[reportUnknownVariableType]
        return value

    @staticmethod
    def _parse_azure_ai_search_output_payload(output: Any) -> Mapping[str, Any] | None:
        """Parse an Azure AI Search tool output payload from a streamed Responses event."""
        if isinstance(output, str):
            try:
                output = json.loads(output)
            except json.JSONDecodeError:
                logger.debug("Unable to parse Azure AI Search call output JSON.", exc_info=True)
                return None

        output = RawOpenAIChatClient._serialize_provider_payload(output)
        if isinstance(output, Mapping):
            return cast("Mapping[str, Any]", output)
        return None

    @staticmethod
    def _extract_azure_ai_search_output_payload(event: Any) -> Mapping[str, Any] | None:
        """Return Azure AI Search output payload from either a top-level event or its nested item."""
        payload = RawOpenAIChatClient._parse_azure_ai_search_output_payload(getattr(event, "output", None))
        if payload is not None:
            return payload

        item = getattr(event, "item", None)
        if getattr(item, "type", None) == _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE:
            return RawOpenAIChatClient._parse_azure_ai_search_output_payload(getattr(item, "output", None))
        return None

    @staticmethod
    def _extract_azure_ai_search_get_urls(event: Any) -> list[str]:
        """Extract per-document Azure AI Search REST URLs from a streamed Responses event."""
        event_type = getattr(event, "type", None)
        if event_type not in _AZURE_AI_SEARCH_OUTPUT_EVENT_TYPES and not (
            isinstance(event_type, str) and event_type.startswith(_AZURE_AI_SEARCH_OUTPUT_EVENT_PREFIX)
        ):
            return []

        payload = RawOpenAIChatClient._extract_azure_ai_search_output_payload(event)
        if payload is None:
            return []

        get_urls = payload.get("get_urls")
        if not isinstance(get_urls, Sequence) or isinstance(get_urls, (str, bytes, bytearray)):
            return []

        urls: list[str] = []
        for url in cast("Sequence[object]", get_urls):
            if isinstance(url, str) and url:
                urls.append(url)
        return urls

    @staticmethod
    def _azure_ai_search_doc_index(annotation: Annotation) -> int | None:
        """Return the document index encoded in a Foundry Azure AI Search `doc_N` citation title."""
        title = annotation.get("title")
        if not isinstance(title, str) or not title.startswith("doc_"):
            return None
        index_text = title.removeprefix("doc_")
        if not index_text.isdigit():
            return None
        return int(index_text)

    @classmethod
    def _enrich_streamed_azure_ai_search_citations(cls, updates: Sequence[ChatResponseUpdate]) -> None:
        """Enrich streamed Azure AI Search citation annotations with per-document REST URLs."""
        # Azure AI Search citations are numbered with global `doc_N` ordinals across the
        # whole streamed response, so concatenate `get_urls` in event order before resolving them.
        get_urls: list[str] = []
        for update in updates:
            get_urls.extend(cls._extract_azure_ai_search_get_urls(update.raw_representation))
        if not get_urls:
            return

        for update in updates:
            for content in update.contents:
                if content.type != "text" or not content.annotations:
                    continue
                for annotation in content.annotations:
                    if annotation.get("type") != "citation" or annotation.get("file_id"):
                        continue

                    doc_index = cls._azure_ai_search_doc_index(annotation)
                    if doc_index is None or doc_index >= len(get_urls):
                        continue

                    additional_properties = annotation.get("additional_properties")
                    if not isinstance(additional_properties, dict):
                        additional_properties = {}
                        annotation["additional_properties"] = additional_properties
                    if "get_url" in additional_properties:
                        continue

                    additional_properties["get_url"] = get_urls[doc_index]

    @staticmethod
    def _extract_mcp_search_documents_from_text(text: str) -> dict[str, Mapping[str, Any]]:
        """Extract MCP search-index document metadata JSON objects from hosted-MCP output text."""
        documents: dict[str, Mapping[str, Any]] = {}
        decoder = json.JSONDecoder()
        start = 0
        while True:
            object_start = text.find("{", start)
            if object_start < 0:
                return documents
            try:
                value, offset = decoder.raw_decode(text[object_start:])
            except json.JSONDecodeError:
                start = object_start + 1
                continue
            if isinstance(value, Mapping):
                document = cast("Mapping[str, Any]", value)
                document_id = document.get("id")
                if isinstance(document_id, str) and document_id:
                    documents[document_id] = document
            start = object_start + offset

    @classmethod
    def _extract_mcp_search_documents_from_content(cls, content: Content) -> dict[str, Mapping[str, Any]]:
        """Extract MCP search-index document metadata from an MCP tool-result content item."""
        documents: dict[str, Mapping[str, Any]] = {}
        if content.type != "mcp_server_tool_result":
            return documents

        def _add_from_output(output: Any) -> None:
            if isinstance(output, str):
                documents.update(cls._extract_mcp_search_documents_from_text(output))
                return
            if isinstance(output, Content):
                if output.type == "text" and isinstance(output.text, str):
                    documents.update(cls._extract_mcp_search_documents_from_text(output.text))
                return
            if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
                for item in cast("Sequence[object]", output):
                    _add_from_output(item)

        _add_from_output(content.output)
        raw_output = getattr(content.raw_representation, "output", None)
        _add_from_output(raw_output)
        return documents

    @staticmethod
    def _mcp_search_document_id(annotation: Annotation) -> str | None:
        """Return the document id encoded in an `mcp://searchindex/<id>` citation."""
        for key in ("url", "title"):
            value = annotation.get(key)
            if isinstance(value, str) and value.startswith("mcp://searchindex/"):
                return value.removeprefix("mcp://searchindex/").split("?", 1)[0].split("#", 1)[0]
        return None

    @classmethod
    def _enrich_mcp_search_citations(cls, contents: Sequence[Content]) -> None:
        """Add MCP search-index document metadata to matching citation annotations."""
        documents: dict[str, Mapping[str, Any]] = {}
        for content in contents:
            documents.update(cls._extract_mcp_search_documents_from_content(content))
        if not documents:
            return

        for content in contents:
            if content.type != "text" or not content.annotations:
                continue
            for annotation in content.annotations:
                document_id = cls._mcp_search_document_id(annotation)
                if document_id is None:
                    continue
                document = documents.get(document_id)
                if document is None:
                    continue
                additional_properties = annotation.setdefault("additional_properties", {})
                additional_properties.setdefault("mcp_document_id", document_id)
                document_title = document.get("title")
                if isinstance(document_title, str) and document_title:
                    additional_properties.setdefault("document_title", document_title)
                source = document.get("source")
                if isinstance(source, str) and source:
                    additional_properties.setdefault("source", source)

    @staticmethod
    def _get_search_tool_name(item_type: str) -> str:
        """Map OpenAI search output item types to unified content tool names."""
        return "web_search" if item_type == "web_search_call" else "file_search"

    def _parse_search_tool_call_content(self, item: Any) -> Content:
        """Create unified search tool call content from an OpenAI search output item."""
        item_type = getattr(item, "type", "")
        call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or ""
        if item_type == "web_search_call":
            arguments = self._serialize_provider_payload(getattr(item, "action", None))
        else:
            arguments = {"queries": list(getattr(item, "queries", []) or [])}
        return Content.from_search_tool_call(
            call_id=call_id,
            tool_name=self._get_search_tool_name(item_type),
            arguments=arguments,
            status=getattr(item, "status", None),
            raw_representation=item,
        )

    def _parse_search_tool_result_content(self, item: Any) -> Content:
        """Create unified search tool result content from an OpenAI search output item."""
        item_type = getattr(item, "type", "")
        call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or ""
        if item_type == "web_search_call":
            result = {"action": self._serialize_provider_payload(getattr(item, "action", None))}
        else:
            result = {"results": self._serialize_provider_payload(getattr(item, "results", None))}
        return Content.from_search_tool_result(
            call_id=call_id,
            tool_name=self._get_search_tool_name(item_type),
            result=result,
            status=getattr(item, "status", None),
            raw_representation=item,
        )

    def _parse_hosted_function_call_content(
        self,
        item: ResponseCustomToolCall | ResponseToolSearchCall,
        *,
        name: str,
        arguments: Any = None,
    ) -> Content:
        """Create informational-only function call content for hosted Responses items."""
        additional_properties: dict[str, Any] = {"item_type": item.type}
        match item.type:
            case "custom_tool_call":
                call_id = item.call_id
                if item.id:
                    additional_properties["item_id"] = item.id
                if item.namespace:
                    additional_properties["namespace"] = item.namespace
            case "tool_search_call":
                call_id = item.call_id or item.id
                additional_properties["item_id"] = item.id
                additional_properties["status"] = item.status
                additional_properties["execution"] = item.execution
                if item.created_by:
                    additional_properties["created_by"] = item.created_by
        return Content.from_function_call(
            call_id=call_id,
            name=name,
            arguments=self._serialize_provider_payload(arguments),
            informational_only=True,
            additional_properties=additional_properties,
            raw_representation=item,
        )

    # region Parse methods
    def _parse_response_from_openai(
        self,
        response: OpenAIResponse | ParsedResponse[BaseModel],
        options: dict[str, Any],
    ) -> ChatResponse:
        """Parse an OpenAI Responses API response into a ChatResponse."""
        structured_response: BaseModel | None = response.output_parsed if isinstance(response, ParsedResponse) else None  # type: ignore[reportUnknownMemberType]

        metadata: dict[str, Any] = response.metadata or {}
        contents: list[Content] = []
        local_shell_tool_name = self._get_local_shell_tool_name(options.get("tools"))
        try:
            response_outputs = response.output  # type: ignore[reportUnknownMemberType]
        except AttributeError:
            response_outputs = []
        for item in response_outputs:  # type: ignore[reportUnknownVariableType]
            match item.type:
                # types:
                # ParsedResponseOutputMessage[Unknown] |
                # ParsedResponseFunctionToolCall |
                # ResponseFileSearchToolCall |
                # ResponseFunctionWebSearch |
                # ResponseComputerToolCall |
                # ResponseReasoningItem |
                # MCPCall |
                # MCPApprovalRequest |
                # ImageGenerationCall |
                # LocalShellCall |
                # LocalShellCallAction |
                # MCPListTools |
                # ResponseCodeInterpreterToolCall |
                # ResponseCustomToolCall |
                # ParsedResponseOutputMessage[BaseModel] |
                # ResponseOutputMessage |
                # ResponseFunctionToolCall
                case "message":  # ResponseOutputMessage
                    for message_content in item.content:  # type: ignore[reportMissingTypeArgument]
                        match message_content.type:
                            case "output_text":
                                text_content = Content.from_text(
                                    text=message_content.text,
                                    raw_representation=message_content,
                                )
                                metadata.update(self._get_metadata_from_response(message_content))
                                if message_content.annotations:
                                    text_content.annotations = []
                                    for annotation in message_content.annotations:
                                        match annotation.type:
                                            case "file_path":
                                                text_content.annotations.append(  # pyright: ignore[reportUnknownMemberType]
                                                    Annotation(
                                                        type="citation",
                                                        file_id=annotation.file_id,
                                                        additional_properties={
                                                            "index": annotation.index,
                                                        },
                                                        raw_representation=annotation,
                                                    )
                                                )
                                            case "file_citation":
                                                text_content.annotations.append(  # pyright: ignore[reportUnknownMemberType]
                                                    Annotation(
                                                        type="citation",
                                                        url=annotation.filename,
                                                        file_id=annotation.file_id,
                                                        raw_representation=annotation,
                                                        additional_properties={
                                                            "index": annotation.index,
                                                        },
                                                    )
                                                )
                                            case "url_citation":
                                                text_content.annotations.append(  # pyright: ignore[reportUnknownMemberType]
                                                    Annotation(
                                                        type="citation",
                                                        title=annotation.title,
                                                        url=annotation.url,
                                                        annotated_regions=[
                                                            TextSpanRegion(
                                                                type="text_span",
                                                                start_index=annotation.start_index,
                                                                end_index=annotation.end_index,
                                                            )
                                                        ],
                                                        raw_representation=annotation,
                                                    )
                                                )
                                            case "container_file_citation":
                                                text_content.annotations.append(  # pyright: ignore[reportUnknownMemberType]
                                                    Annotation(
                                                        type="citation",
                                                        file_id=annotation.file_id,
                                                        url=annotation.filename,
                                                        additional_properties={
                                                            "container_id": annotation.container_id,
                                                        },
                                                        annotated_regions=[
                                                            TextSpanRegion(
                                                                type="text_span",
                                                                start_index=annotation.start_index,
                                                                end_index=annotation.end_index,
                                                            )
                                                        ],
                                                        raw_representation=annotation,
                                                    )
                                                )
                                            case _:
                                                logger.debug(
                                                    "Unparsed annotation type: %s",
                                                    annotation.type,
                                                )
                                contents.append(text_content)
                            case "refusal":
                                contents.append(
                                    Content.from_text(
                                        text=message_content.refusal,
                                        raw_representation=message_content,
                                    )
                                )
                case "reasoning":  # ResponseOutputReasoning
                    added_reasoning = False
                    if item_content := getattr(item, "content", None):
                        for index, reasoning_content in enumerate(item_content):
                            additional_properties: dict[str, Any] = {}
                            if hasattr(item, "summary") and item.summary and index < len(item.summary):
                                additional_properties["summary"] = item.summary[index]
                            contents.append(
                                Content.from_text_reasoning(
                                    id=item.id,
                                    text=reasoning_content.text,
                                    raw_representation=reasoning_content,
                                    additional_properties=additional_properties or None,
                                )
                            )
                            added_reasoning = True
                    if item_summary := getattr(item, "summary", None):
                        for summary in item_summary:
                            contents.append(
                                Content.from_text_reasoning(
                                    id=item.id,
                                    text=summary.text,
                                    raw_representation=summary,
                                )
                            )
                            added_reasoning = True
                    if not added_reasoning:
                        # Reasoning item with no visible text (e.g. encrypted reasoning).
                        # Always emit an empty marker so co-occurrence detection can be done
                        additional_properties_empty: dict[str, Any] = {}
                        if encrypted := getattr(item, "encrypted_content", None):
                            additional_properties_empty["encrypted_content"] = encrypted
                        contents.append(
                            Content.from_text_reasoning(
                                id=item.id,
                                text="",
                                raw_representation=item,
                                additional_properties=additional_properties_empty or None,
                            )
                        )
                case "code_interpreter_call":  # ResponseOutputCodeInterpreterCall
                    call_id = getattr(item, "call_id", None) or getattr(item, "id", None)
                    outputs: list[Content] = []
                    if item_outputs := getattr(item, "outputs", None):
                        for code_output in item_outputs:
                            if getattr(code_output, "type", None) == "logs":
                                outputs.append(
                                    Content.from_text(
                                        text=code_output.logs,
                                        raw_representation=code_output,
                                    )
                                )
                            elif getattr(code_output, "type", None) == "image":
                                outputs.append(
                                    Content.from_uri(
                                        uri=code_output.url,
                                        raw_representation=code_output,
                                        media_type="image",
                                    )
                                )
                    if code := getattr(item, "code", None):
                        contents.append(
                            Content.from_code_interpreter_tool_call(
                                call_id=call_id,
                                inputs=[Content.from_text(text=code, raw_representation=item)],
                                raw_representation=item,
                            )
                        )
                    contents.append(
                        Content.from_code_interpreter_tool_result(
                            call_id=call_id,
                            outputs=outputs,
                            raw_representation=item,
                        )
                    )
                case "function_call":  # ResponseOutputFunctionCall
                    contents.append(
                        Content.from_function_call(
                            call_id=item.call_id,
                            name=item.name,
                            arguments=item.arguments,
                            additional_properties={"fc_id": item.id, "status": item.status},
                            raw_representation=item,
                        )
                    )
                case "custom_tool_call":
                    contents.append(
                        self._parse_hosted_function_call_content(item, name=item.name, arguments=item.input)
                    )
                case "tool_search_call":
                    contents.append(
                        self._parse_hosted_function_call_content(
                            item,
                            name="tool_search",
                            arguments=item.arguments,
                        )
                    )
                case "web_search_call" | "file_search_call":
                    contents.append(self._parse_search_tool_call_content(item))
                    contents.append(self._parse_search_tool_result_content(item))
                case "mcp_approval_request":  # ResponseOutputMcpApprovalRequest
                    contents.append(
                        Content.from_function_approval_request(
                            id=item.id,
                            function_call=Content.from_function_call(
                                call_id=item.id,
                                name=item.name,
                                arguments=item.arguments,
                                additional_properties={"server_label": item.server_label},
                                raw_representation=item,
                            ),
                        )
                    )
                case "mcp_call":
                    call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or ""
                    contents.append(
                        Content.from_mcp_server_tool_call(
                            call_id=call_id,
                            tool_name=item.name,
                            server_name=item.server_label,
                            arguments=item.arguments,
                            raw_representation=item,
                        )
                    )
                    if item.output is not None:
                        contents.append(
                            Content.from_mcp_server_tool_result(
                                call_id=call_id,
                                output=[Content.from_text(text=item.output)],
                                raw_representation=item,
                            )
                        )
                case "image_generation_call":  # ResponseOutputImageGenerationCall
                    image_output: Content | None = None
                    if item.result is not None:
                        # item.result contains raw base64 string
                        # so we call detect_media_type_from_base64 to get the media type and fallback to image/png
                        image_output = Content.from_uri(
                            uri=f"data:{detect_media_type_from_base64(data_str=item.result) or 'image/png'}"
                            f";base64,{item.result}",
                            raw_representation=item.result,
                        )
                    image_id = item.id
                    contents.append(
                        Content.from_image_generation_tool_call(
                            image_id=image_id,
                            raw_representation=item,
                        )
                    )
                    contents.append(
                        Content.from_image_generation_tool_result(
                            image_id=image_id,
                            outputs=image_output,
                            raw_representation=item,
                        )
                    )
                case "shell_call" | "local_shell_call" | "shell_call_output":
                    contents.extend(self._shell_item_to_contents(item, local_shell_tool_name))
                case _:
                    logger.debug("Unparsed output of type: %s: %s", item.type, item)
        response_message = Message(role="assistant", contents=contents)
        args: dict[str, Any] = {
            "response_id": response.id,
            "created_at": datetime.fromtimestamp(response.created_at, tz=timezone.utc).strftime(
                "%Y-%m-%dT%H:%M:%S.%fZ"
            ),
            "messages": response_message,
            "model": response.model,
            "additional_properties": metadata,
            "raw_representation": response,
        }

        if conversation_id := self._get_conversation_id(response, options.get("store")):  # pyright: ignore[reportUnknownArgumentType]
            args["conversation_id"] = conversation_id
        if response.usage and (usage_details := self._parse_usage_from_openai(response.usage)):
            args["usage_details"] = usage_details
        if structured_response:
            args["value"] = structured_response
        elif response_format := options.get("response_format"):
            args["response_format"] = response_format
        # Set continuation_token when background operation is still in progress
        if response.status and response.status in ("in_progress", "queued"):
            args["continuation_token"] = OpenAIContinuationToken(response_id=response.id)
        chat_response = ChatResponse(**args)
        if chat_response.messages:
            self._enrich_mcp_search_citations(chat_response.messages[0].contents)
        return chat_response

    def _parse_chunk_from_openai(
        self,
        event: OpenAIResponseStreamEvent,
        options: dict[str, Any],
        function_call_ids: dict[int, tuple[str, str]],
        seen_reasoning_delta_item_ids: set[str] | None = None,
    ) -> ChatResponseUpdate:
        """Parse an OpenAI Responses API streaming event into a ChatResponseUpdate."""
        metadata: dict[str, Any] = {}
        contents: list[Content] = []
        local_shell_tool_name = self._get_local_shell_tool_name(options.get("tools"))
        conversation_id: str | None = None
        response_id: str | None = None
        created_at: str | None = None
        continuation_token: OpenAIContinuationToken | None = None
        model = self.model
        match event.type:
            # types:
            # ResponseAudioDeltaEvent,
            # ResponseAudioDoneEvent,
            # ResponseAudioTranscriptDeltaEvent,
            # ResponseAudioTranscriptDoneEvent,
            # ResponseCodeInterpreterCallCodeDeltaEvent,
            # ResponseCodeInterpreterCallCodeDoneEvent,
            # ResponseCodeInterpreterCallCompletedEvent,
            # ResponseCodeInterpreterCallInProgressEvent,
            # ResponseCodeInterpreterCallInterpretingEvent,
            # ResponseCompletedEvent,
            # ResponseContentPartAddedEvent,
            # ResponseContentPartDoneEvent,
            # ResponseCreatedEvent,
            # ResponseErrorEvent,
            # ResponseFileSearchCallCompletedEvent,
            # ResponseFileSearchCallInProgressEvent,
            # ResponseFileSearchCallSearchingEvent,
            # ResponseFunctionCallArgumentsDeltaEvent,
            # ResponseFunctionCallArgumentsDoneEvent,
            # ResponseInProgressEvent,
            # ResponseFailedEvent,
            # ResponseIncompleteEvent,
            # ResponseOutputItemAddedEvent,
            # ResponseOutputItemDoneEvent,
            # ResponseReasoningSummaryPartAddedEvent,
            # ResponseReasoningSummaryPartDoneEvent,
            # ResponseReasoningSummaryTextDeltaEvent,
            # ResponseReasoningSummaryTextDoneEvent,
            # ResponseReasoningTextDeltaEvent,
            # ResponseReasoningTextDoneEvent,
            # ResponseRefusalDeltaEvent,
            # ResponseRefusalDoneEvent,
            # ResponseTextDeltaEvent,
            # ResponseTextDoneEvent,
            # ResponseWebSearchCallCompletedEvent,
            # ResponseWebSearchCallInProgressEvent,
            # ResponseWebSearchCallSearchingEvent,
            # ResponseImageGenCallCompletedEvent,
            # ResponseImageGenCallGeneratingEvent,
            # ResponseImageGenCallInProgressEvent,
            # ResponseImageGenCallPartialImageEvent,
            # ResponseMcpCallArgumentsDeltaEvent,
            # ResponseMcpCallArgumentsDoneEvent,
            # ResponseMcpCallCompletedEvent,
            # ResponseMcpCallFailedEvent,
            # ResponseMcpCallInProgressEvent,
            # ResponseMcpListToolsCompletedEvent,
            # ResponseMcpListToolsFailedEvent,
            # ResponseMcpListToolsInProgressEvent,
            # ResponseOutputTextAnnotationAddedEvent,
            # ResponseQueuedEvent,
            # ResponseCustomToolCallInputDeltaEvent,
            # ResponseCustomToolCallInputDoneEvent,
            case "response.content_part.added":
                event_part = event.part
                match event_part.type:
                    case "output_text":
                        contents.append(Content.from_text(text=event_part.text, raw_representation=event))
                        metadata.update(self._get_metadata_from_response(event_part))
                    case "refusal":
                        contents.append(Content.from_text(text=event_part.refusal, raw_representation=event))
                    case _:
                        pass
            case "response.output_text.delta":
                contents.append(Content.from_text(text=event.delta, raw_representation=event))
                metadata.update(self._get_metadata_from_response(event))
            case "response.reasoning_text.delta":
                if seen_reasoning_delta_item_ids is not None:
                    seen_reasoning_delta_item_ids.add(event.item_id)
                contents.append(
                    Content.from_text_reasoning(
                        id=event.item_id,
                        text=event.delta,
                        raw_representation=event,
                    )
                )
                metadata.update(self._get_metadata_from_response(event))
            case "response.reasoning_text.done":
                # Done event carries the full accumulated text. Emit it only as a
                # fallback when no delta was already received for this item_id, to
                # avoid duplicating content in downstream accumulators (e.g. ag-ui).
                if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids:
                    contents.append(
                        Content.from_text_reasoning(
                            id=event.item_id,
                            text=event.text,
                            raw_representation=event,
                        )
                    )
                metadata.update(self._get_metadata_from_response(event))
            case "response.reasoning_summary_text.delta":
                if seen_reasoning_delta_item_ids is not None:
                    seen_reasoning_delta_item_ids.add(event.item_id)
                contents.append(
                    Content.from_text_reasoning(
                        id=event.item_id,
                        text=event.delta,
                        raw_representation=event,
                    )
                )
                metadata.update(self._get_metadata_from_response(event))
            case "response.reasoning_summary_text.done":
                # Done event carries the full accumulated text. Emit it only as a
                # fallback when no delta was already received for this item_id, to
                # avoid duplicating content in downstream accumulators (e.g. ag-ui).
                if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids:
                    contents.append(
                        Content.from_text_reasoning(
                            id=event.item_id,
                            text=event.text,
                            raw_representation=event,
                        )
                    )
                metadata.update(self._get_metadata_from_response(event))
            case "response.code_interpreter_call_code.delta":
                call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id
                ci_additional_properties = {
                    "output_index": event.output_index,
                    "sequence_number": event.sequence_number,
                    "item_id": event.item_id,
                }
                contents.append(
                    Content.from_code_interpreter_tool_call(
                        call_id=call_id,
                        inputs=[
                            Content.from_text(
                                text=event.delta,
                                raw_representation=event,
                                additional_properties=ci_additional_properties,
                            )
                        ],
                        raw_representation=event,
                        additional_properties=ci_additional_properties,
                    )
                )
                metadata.update(self._get_metadata_from_response(event))
                # NOTE: Unlike reasoning done events, code_interpreter done events always
                # emit content because downstream consumers do not accumulate
                # code_interpreter deltas the same way.
            case "response.code_interpreter_call_code.done":
                call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id
                ci_additional_properties = {
                    "output_index": event.output_index,
                    "sequence_number": event.sequence_number,
                    "item_id": event.item_id,
                }
                contents.append(
                    Content.from_code_interpreter_tool_call(
                        call_id=call_id,
                        inputs=[
                            Content.from_text(
                                text=event.code,
                                raw_representation=event,
                                additional_properties=ci_additional_properties,
                            )
                        ],
                        raw_representation=event,
                        additional_properties=ci_additional_properties,
                    )
                )
                metadata.update(self._get_metadata_from_response(event))
            case "response.created":
                response_id = event.response.id
                conversation_id = self._get_conversation_id(event.response, options.get("store"))
                if event.response.status and event.response.status in (
                    "in_progress",
                    "queued",
                ):
                    continuation_token = OpenAIContinuationToken(response_id=event.response.id)
            case "response.in_progress":
                response_id = event.response.id
                conversation_id = self._get_conversation_id(event.response, options.get("store"))
                continuation_token = OpenAIContinuationToken(response_id=event.response.id)
            case "response.completed":
                response_id = event.response.id
                conversation_id = self._get_conversation_id(event.response, options.get("store"))
                model = event.response.model
                created_at = datetime.fromtimestamp(event.response.created_at, tz=timezone.utc).strftime(
                    "%Y-%m-%dT%H:%M:%S.%fZ"
                )
                if event.response.usage:
                    usage = self._parse_usage_from_openai(event.response.usage)
                    if usage:
                        contents.append(Content.from_usage(usage_details=usage, raw_representation=event))
            case "response.output_item.added":
                event_item = event.item
                match event_item.type:
                    # types:
                    # ResponseOutputMessage,
                    # ResponseFileSearchToolCall,
                    # ResponseFunctionToolCall,
                    # ResponseFunctionWebSearch,
                    # ResponseComputerToolCall,
                    # ResponseReasoningItem,
                    # ImageGenerationCall,
                    # ResponseCodeInterpreterToolCall,
                    # LocalShellCall,
                    # McpCall,
                    # McpListTools,
                    # McpApprovalRequest,
                    # ResponseCustomToolCall,
                    case "function_call":
                        function_call_ids[event.output_index] = (
                            event_item.call_id,
                            event_item.name,
                        )
                    case "mcp_approval_request":
                        contents.append(
                            Content.from_function_approval_request(
                                id=event_item.id,
                                function_call=Content.from_function_call(
                                    call_id=event_item.id,
                                    name=event_item.name,
                                    arguments=event_item.arguments,
                                    additional_properties={"server_label": event_item.server_label},
                                    raw_representation=event_item,
                                ),
                            )
                        )
                    case "mcp_call":
                        call_id = getattr(event_item, "id", None) or getattr(event_item, "call_id", None) or ""
                        contents.append(
                            Content.from_mcp_server_tool_call(
                                call_id=call_id,
                                tool_name=getattr(event_item, "name", "") or "",
                                server_name=getattr(event_item, "server_label", None),
                                arguments=getattr(event_item, "arguments", None),
                                raw_representation=event_item,
                            )
                        )
                        # Result deferred to response.output_item.done
                    case "code_interpreter_call":  # ResponseOutputCodeInterpreterCall
                        call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None)
                        outputs: list[Content] = []
                        if hasattr(event_item, "outputs") and event_item.outputs:
                            for code_output in event_item.outputs:
                                if getattr(code_output, "type", None) == "logs":
                                    outputs.append(
                                        Content.from_text(
                                            text=cast(Any, code_output).logs,
                                            raw_representation=code_output,
                                        )
                                    )
                                elif getattr(code_output, "type", None) == "image":
                                    outputs.append(
                                        Content.from_uri(
                                            uri=cast(Any, code_output).url,
                                            raw_representation=code_output,
                                            media_type="image",
                                        )
                                    )
                        if hasattr(event_item, "code") and event_item.code:
                            contents.append(
                                Content.from_code_interpreter_tool_call(
                                    call_id=call_id,
                                    inputs=[
                                        Content.from_text(
                                            text=event_item.code,
                                            raw_representation=event_item,
                                        )
                                    ],
                                    raw_representation=event_item,
                                )
                            )
                        contents.append(
                            Content.from_code_interpreter_tool_result(
                                call_id=call_id,
                                outputs=outputs,
                                raw_representation=event_item,
                            )
                        )
                    case "shell_call" | "local_shell_call" | "shell_call_output":
                        # Shell items carry their command/output only on the
                        # `response.output_item.done` event; the `.added` snapshot is an
                        # in-progress skeleton with an empty action. Parsed in the
                        # `response.output_item.done` handler instead.
                        pass
                    case "reasoning":  # ResponseOutputReasoning
                        reasoning_id = getattr(event_item, "id", None)
                        added_reasoning = False
                        if hasattr(event_item, "content") and event_item.content:
                            for index, reasoning_content in enumerate(event_item.content):
                                additional_properties: dict[str, Any] = {}
                                if (
                                    hasattr(event_item, "summary")
                                    and event_item.summary
                                    and index < len(event_item.summary)
                                ):
                                    additional_properties["summary"] = event_item.summary[index]
                                contents.append(
                                    Content.from_text_reasoning(
                                        id=reasoning_id or None,
                                        text=reasoning_content.text,
                                        raw_representation=reasoning_content,
                                        additional_properties=additional_properties or None,
                                    )
                                )
                                added_reasoning = True
                        if not added_reasoning:
                            # Reasoning item with no visible text (e.g. encrypted reasoning).
                            # Always emit an empty marker so co-occurrence detection can occur.
                            additional_properties_empty: dict[str, Any] = {}
                            if encrypted := getattr(event_item, "encrypted_content", None):
                                additional_properties_empty["encrypted_content"] = encrypted
                            contents.append(
                                Content.from_text_reasoning(
                                    id=reasoning_id or None,
                                    text="",
                                    raw_representation=event_item,
                                    additional_properties=additional_properties_empty or None,
                                )
                            )
                    case "web_search_call" | "file_search_call":
                        contents.append(self._parse_search_tool_call_content(event_item))
                    case _:
                        if getattr(event_item, "type", None) != _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE:
                            logger.debug("Unparsed event of type: %s: %s", event.type, event)
            case (
                "response.web_search_call.in_progress"
                | "response.web_search_call.searching"
                | "response.web_search_call.completed"
                | "response.file_search_call.in_progress"
                | "response.file_search_call.searching"
                | "response.file_search_call.completed"
            ):
                pass
            case "response.function_call_arguments.delta":
                call_id, name = function_call_ids.get(event.output_index, (None, None))
                if call_id and name:
                    contents.append(
                        Content.from_function_call(
                            call_id=call_id,
                            name=name,
                            arguments=event.delta,
                            additional_properties={
                                "output_index": event.output_index,
                                "fc_id": event.item_id,
                            },
                            raw_representation=event,
                        )
                    )
            case "response.image_generation_call.partial_image":
                # Handle streaming partial image generation
                image_base64 = event.partial_image_b64
                partial_index = event.partial_image_index
                image_output = Content.from_uri(
                    uri=f"data:{detect_media_type_from_base64(data_str=image_base64) or 'image/png'}"
                    f";base64,{image_base64}",
                    additional_properties={
                        "partial_image_index": partial_index,
                        "is_partial_image": True,
                    },
                    raw_representation=event,
                )

                image_id = getattr(event, "item_id", None)
                contents.append(
                    Content.from_image_generation_tool_call(
                        image_id=image_id,
                        raw_representation=event,
                    )
                )
                contents.append(
                    Content.from_image_generation_tool_result(
                        image_id=image_id,
                        outputs=image_output,
                        raw_representation=event,
                    )
                )
            case "response.output_text.annotation.added":
                # Handle streaming text annotations (file citations, file paths, etc.)
                annotation: Any = event.annotation

                def _get_ann_value(key: str) -> Any:
                    """Extract value from annotation (dict or object)."""
                    if isinstance(annotation, dict):
                        return cast("dict[str, Any]", annotation).get(key)
                    return getattr(annotation, key, None)

                ann_type = _get_ann_value("type")
                ann_file_id = _get_ann_value("file_id")
                # Hosted-file citations attach as text annotations (matching the non-streaming path)
                # so they don't roundtrip as standalone `input_file` items in assistant history.
                if ann_type == "file_path":
                    if ann_file_id:
                        annotation_obj = Annotation(
                            type="citation",
                            file_id=str(ann_file_id),
                            additional_properties={
                                "annotation_index": event.annotation_index,
                                "index": _get_ann_value("index"),
                            },
                            raw_representation=annotation,
                        )
                        contents.append(
                            Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
                        )
                elif ann_type == "file_citation":
                    if ann_file_id:
                        ann_filename = _get_ann_value("filename")
                        annotation_obj = Annotation(
                            type="citation",
                            file_id=str(ann_file_id),
                            url=ann_filename,
                            additional_properties={
                                "annotation_index": event.annotation_index,
                                "index": _get_ann_value("index"),
                            },
                            raw_representation=annotation,
                        )
                        contents.append(
                            Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
                        )
                elif ann_type == "container_file_citation":
                    if ann_file_id:
                        ann_filename = _get_ann_value("filename")
                        ann_start = _get_ann_value("start_index")
                        ann_end = _get_ann_value("end_index")
                        annotation_obj = Annotation(
                            type="citation",
                            file_id=str(ann_file_id),
                            url=ann_filename,
                            additional_properties={
                                "annotation_index": event.annotation_index,
                                "container_id": _get_ann_value("container_id"),
                            },
                            raw_representation=annotation,
                        )
                        if ann_start is not None and ann_end is not None:
                            annotation_obj["annotated_regions"] = [
                                TextSpanRegion(
                                    type="text_span",
                                    start_index=ann_start,
                                    end_index=ann_end,
                                )
                            ]
                        contents.append(
                            Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
                        )
                elif ann_type == "url_citation":
                    ann_url = _get_ann_value("url")
                    if ann_url:
                        ann_start = _get_ann_value("start_index")
                        ann_end = _get_ann_value("end_index")
                        annotation_properties: dict[str, Any] = {"annotation_index": event.annotation_index}
                        ann_get_url = _get_ann_value("get_url")
                        if ann_get_url is not None:
                            annotation_properties["get_url"] = ann_get_url
                        annotation_obj = Annotation(
                            type="citation",
                            title=_get_ann_value("title") or "",
                            url=str(ann_url),
                            additional_properties=annotation_properties,
                            raw_representation=annotation,
                        )
                        if ann_start is not None and ann_end is not None:
                            annotation_obj["annotated_regions"] = [
                                TextSpanRegion(
                                    type="text_span",
                                    start_index=ann_start,
                                    end_index=ann_end,
                                )
                            ]
                        contents.append(
                            Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
                        )
                else:
                    logger.debug("Unparsed annotation type in streaming: %s", ann_type)
            case "response.output_item.done":
                done_item = event.item
                if getattr(done_item, "type", None) == "mcp_call":
                    call_id = getattr(done_item, "id", None) or getattr(done_item, "call_id", None) or ""
                    output_text = getattr(done_item, "output", None)
                    parsed_output: list[Content] | None = (
                        [Content.from_text(text=output_text)] if isinstance(output_text, str) else None
                    )
                    contents.append(
                        Content.from_mcp_server_tool_result(
                            call_id=call_id,
                            output=parsed_output,
                            raw_representation=done_item,
                        )
                    )
                elif getattr(done_item, "type", None) in ("web_search_call", "file_search_call"):
                    contents.append(self._parse_search_tool_result_content(done_item))
                elif getattr(done_item, "type", None) in ("shell_call", "local_shell_call", "shell_call_output"):
                    # Shell items are parsed here (not on `response.output_item.added`) because the
                    # command/output is only populated on the completed item.
                    contents.extend(self._shell_item_to_contents(done_item, local_shell_tool_name))
                elif getattr(done_item, "type", None) == "custom_tool_call":
                    custom_tool_call = cast(ResponseCustomToolCall, done_item)
                    contents.append(
                        self._parse_hosted_function_call_content(
                            custom_tool_call,
                            name=custom_tool_call.name,
                            arguments=custom_tool_call.input,
                        )
                    )
                elif getattr(done_item, "type", None) == "tool_search_call":
                    tool_search_call = cast(ResponseToolSearchCall, done_item)
                    contents.append(
                        self._parse_hosted_function_call_content(
                            tool_search_call,
                            name="tool_search",
                            arguments=tool_search_call.arguments,
                        )
                    )
                elif getattr(done_item, "type", None) == _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE:
                    pass
            case _:
                if not isinstance(event.type, str) or not event.type.startswith(_AZURE_AI_SEARCH_OUTPUT_EVENT_PREFIX):
                    logger.debug("Unparsed event of type: %s: %s", event.type, event)

        return ChatResponseUpdate(
            contents=contents,
            conversation_id=conversation_id,
            response_id=response_id,
            role="assistant",
            model=model,
            created_at=created_at,
            continuation_token=continuation_token,
            additional_properties=metadata,
            raw_representation=event,
        )

    def _parse_usage_from_openai(self, usage: ResponseUsage) -> UsageDetails | None:
        details = UsageDetails(
            input_token_count=usage.input_tokens,
            output_token_count=usage.output_tokens,
            total_token_count=usage.total_tokens,
        )
        if usage.input_tokens_details:
            cached_tokens = cast("int | None", getattr(usage.input_tokens_details, "cached_tokens", None))
            if cached_tokens is not None:
                details["openai.cached_input_tokens"] = cached_tokens
                details["cache_read_input_token_count"] = cached_tokens
        if usage.output_tokens_details:
            reasoning_tokens = cast("int | None", getattr(usage.output_tokens_details, "reasoning_tokens", None))
            if reasoning_tokens is not None:
                details["openai.reasoning_tokens"] = reasoning_tokens
                details["reasoning_output_token_count"] = reasoning_tokens
        return details

    def _get_metadata_from_response(self, output: Any) -> dict[str, Any]:
        """Get metadata from a chat choice."""
        if logprobs := getattr(output, "logprobs", None):
            return {
                "logprobs": logprobs,
            }
        return {}


class OpenAIChatClient(
    FunctionInvocationLayer[OpenAIChatOptionsT],
    ChatMiddlewareLayer[OpenAIChatOptionsT],
    ChatTelemetryLayer[OpenAIChatOptionsT],
    RawOpenAIChatClient[OpenAIChatOptionsT],
    Generic[OpenAIChatOptionsT],
):
    """OpenAI Responses client class with middleware, telemetry, and function invocation support."""

    OTEL_PROVIDER_NAME: ClassVar[str] = "openai"

    @overload
    def __init__(
        self,
        model: str | None = None,
        *,
        api_key: str | Callable[[], str | Awaitable[str]] | None = None,
        org_id: str | None = None,
        base_url: str | None = None,
        default_headers: Mapping[str, str] | None = None,
        async_client: AsyncOpenAI | None = None,
        instruction_role: str | None = None,
        compaction_strategy: CompactionStrategy | None = None,
        tokenizer: TokenizerProtocol | None = None,
        middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
        function_invocation_configuration: FunctionInvocationConfiguration | None = None,
        additional_properties: dict[str, Any] | None = None,
        env_file_path: str | None = None,
        env_file_encoding: str | None = None,
    ) -> None:
        """Initialize an OpenAI Responses client.

        Keyword Args:
            model: Model identifier to use for the request. When not provided, the constructor
                reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``.
            api_key: API key. When not provided explicitly, the constructor reads
                ``OPENAI_API_KEY``. A callable API key is also supported.
            org_id: OpenAI organization ID. When not provided explicitly, the constructor reads
                ``OPENAI_ORG_ID``.
            base_url: Base URL override. When not provided explicitly, the constructor reads
                ``OPENAI_BASE_URL``.
            default_headers: Additional HTTP headers.
            async_client: Pre-configured OpenAI client.
            instruction_role: Role for instruction messages (for example ``"system"``).
            compaction_strategy: Optional per-client compaction override.
            tokenizer: Optional tokenizer for compaction strategies.
            middleware: Optional middleware to apply to the client.
            function_invocation_configuration: Optional function invocation configuration override.
            additional_properties: Optional additional properties to include on all requests.
            env_file_path: Optional ``.env`` file that is checked before the process environment
                for ``OPENAI_*`` values.
            env_file_encoding: Encoding for the ``.env`` file.
        """
        ...

    @overload
    def __init__(
        self,
        model: str | None = None,
        *,
        azure_endpoint: str | None = None,
        credential: AzureCredentialTypes | AzureTokenProvider | None = None,
        api_version: str | None = None,
        api_key: str | Callable[[], str | Awaitable[str]] | None = None,
        base_url: str | None = None,
        default_headers: Mapping[str, str] | None = None,
        async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None,
        instruction_role: str | None = None,
        compaction_strategy: CompactionStrategy | None = None,
        tokenizer: TokenizerProtocol | None = None,
        middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
        function_invocation_configuration: FunctionInvocationConfiguration | None = None,
        additional_properties: dict[str, Any] | None = None,
        env_file_path: str | None = None,
        env_file_encoding: str | None = None,
    ) -> None:
        """Initialize an OpenAI Responses client.

        Keyword Args:
            model: Model identifier to use for the request. When not provided, the constructor
                reads ``AZURE_OPENAI_CHAT_MODEL`` and then
                ``AZURE_OPENAI_MODEL``.
            azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor
                reads ``AZURE_OPENAI_ENDPOINT``.
            credential: Azure credential or token provider for Entra auth.
            api_version: Azure API version. When not provided explicitly, the constructor reads
                ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default.
            api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key
                auth. A callable token provider is also accepted, but ``credential`` is the preferred
                Azure auth surface.
            base_url: Base URL override. When not provided explicitly, the constructor reads
                ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want
                to pass the full ``.../openai/v1`` base URL directly.
            default_headers: Additional HTTP headers.
            async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on
                Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup.
            instruction_role: Role for instruction messages (for example ``"system"``).
            compaction_strategy: Optional per-client compaction override.
            tokenizer: Optional tokenizer for compaction strategies.
            middleware: Optional middleware to apply to the client.
            function_invocation_configuration: Optional function invocation configuration override.
            additional_properties: Optional additional properties to include on all requests.
            env_file_path: Optional ``.env`` file that is checked before process environment
                variables for ``AZURE_OPENAI_*`` values.
            env_file_encoding: Encoding for the ``.env`` file.
        """
        ...

    def __init__(
        self,
        model: str | None = None,
        *,
        api_key: str | Callable[[], str | Awaitable[str]] | None = None,
        credential: AzureCredentialTypes | AzureTokenProvider | None = None,
        org_id: str | None = None,
        base_url: str | None = None,
        azure_endpoint: str | None = None,
        api_version: str | None = None,
        default_headers: Mapping[str, str] | None = None,
        async_client: AsyncOpenAI | None = None,
        instruction_role: str | None = None,
        compaction_strategy: CompactionStrategy | None = None,
        tokenizer: TokenizerProtocol | None = None,
        middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
        function_invocation_configuration: FunctionInvocationConfiguration | None = None,
        additional_properties: dict[str, Any] | None = None,
        env_file_path: str | None = None,
        env_file_encoding: str | None = None,
    ) -> None:
        """Initialize an OpenAI Responses client.

        Keyword Args:
            model: Model identifier to use for the request. When not provided, the constructor
                reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI
                routing, or ``AZURE_OPENAI_CHAT_MODEL`` and then
                ``AZURE_OPENAI_MODEL`` for Azure routing.
            api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``.
                For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key
                auth. A callable token provider is also accepted for backwards compatibility,
                but ``credential`` is the preferred Azure auth surface.
            credential: Azure credential or token provider for Azure OpenAI auth. Passing this
                is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured.
                Credential objects require the optional ``azure-identity`` package.
            org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from
                ``OPENAI_ORG_ID`` when not provided.
            base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``.
                For Azure routing this may be used instead of ``azure_endpoint`` when you want
                to pass the full ``.../openai/v1`` base URL directly.
            azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing
                falls back to ``AZURE_OPENAI_ENDPOINT``.
            api_version: Azure API version to use once Azure routing is selected. When
                not provided explicitly, Azure routing falls back to
                ``AZURE_OPENAI_API_VERSION`` and then the Responses default.
            default_headers: Default HTTP headers that are merged into each request.
            async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on
                Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup.
            instruction_role: Role to use for instruction messages (for example ``"system"``).
            compaction_strategy: Optional per-client compaction override.
            tokenizer: Optional tokenizer for compaction strategies.
            middleware: Optional middleware to apply to the client.
            function_invocation_configuration: Optional function invocation configuration override.
            additional_properties: Additional properties stored on the client instance.
            env_file_path: Optional ``.env`` file that is checked before process environment
                variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*``
                lookups.
            env_file_encoding: Encoding for the ``.env`` file.

        Notes:
            Environment resolution and routing precedence are:

            1. Explicit Azure inputs (``azure_endpoint`` or ``credential``)
            2. Explicit OpenAI API key or ``OPENAI_API_KEY``
            3. Azure environment fallback

            OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``,
            ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing
            reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``,
            ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_MODEL``,
            ``AZURE_OPENAI_MODEL``, and ``AZURE_OPENAI_API_VERSION``.

        Examples:
            .. code-block:: python

                from agent_framework.openai import OpenAIChatClient

                # Using environment variables
                # Set OPENAI_API_KEY=sk-...
                # Set OPENAI_MODEL=gpt-4o
                client = OpenAIChatClient()

                # Or passing parameters directly
                client = OpenAIChatClient(model="gpt-4o", api_key="sk-...")

                # Or loading from a .env file
                client = OpenAIChatClient(env_file_path="path/to/.env")

                # Using custom ChatOptions with type safety:
                from typing import TypedDict
                from agent_framework.openai import OpenAIChatOptions


                class MyOptions(OpenAIChatOptions, total=False):
                    my_custom_option: str


                client: OpenAIChatClient[MyOptions] = OpenAIChatClient(model="gpt-4o")
                response = await client.get_response("Hello", options={"my_custom_option": "value"})
        """
        super().__init__(
            model=model,
            api_key=api_key,
            credential=credential,
            org_id=org_id,
            base_url=base_url,
            azure_endpoint=azure_endpoint,
            api_version=api_version,
            default_headers=default_headers,
            async_client=async_client,
            instruction_role=instruction_role,
            env_file_path=env_file_path,
            env_file_encoding=env_file_encoding,
            middleware=middleware,
            function_invocation_configuration=function_invocation_configuration,
            compaction_strategy=compaction_strategy,
            tokenizer=tokenizer,
            additional_properties=additional_properties,
        )


def _apply_openai_chat_client_docstrings() -> None:
    """Align OpenAI Responses client docstrings with the raw implementation."""
    from agent_framework._clients import BaseChatClient
    from agent_framework._docstrings import apply_layered_docstring

    apply_layered_docstring(RawOpenAIChatClient.get_response, BaseChatClient.get_response)
    apply_layered_docstring(
        OpenAIChatClient.get_response,
        RawOpenAIChatClient.get_response,
        extra_keyword_args={
            "middleware": """
                Optional per-call chat and function middleware.
                This is merged with any middleware configured on the client for the current request.
            """,
        },
    )


_apply_openai_chat_client_docstrings()
