import asyncio
import json
import os
from contextlib import asynccontextmanager
from typing import Any, Literal

from mcp import Tool
from mcp.types import ContentBlock, TextContent
from openai import APIError, AsyncOpenAI, AuthenticationError, DefaultAioHttpClient

from fast_agent.constants import (
    ANTHROPIC_CITATIONS_CHANNEL,
    ANTHROPIC_SERVER_TOOLS_CHANNEL,
    OPENAI_ASSISTANT_MESSAGE_ITEMS,
    OPENAI_REASONING_ENCRYPTED,
    REASONING,
)
from fast_agent.core.exceptions import ModelConfigError, ProviderKeyError
from fast_agent.core.logging.logger import get_logger
from fast_agent.core.prompt import Prompt
from fast_agent.llm.fastagent_llm import FastAgentLLM
from fast_agent.llm.provider.error_utils import build_stream_failure_response
from fast_agent.llm.provider.openai._stream_capture import (
    save_stream_request as _save_stream_request,
)
from fast_agent.llm.provider.openai._stream_capture import (
    stream_capture_filename as _stream_capture_filename,
)
from fast_agent.llm.provider.openai.responses_content import ResponsesContentMixin
from fast_agent.llm.provider.openai.responses_files import ResponsesFileMixin
from fast_agent.llm.provider.openai.responses_output import ResponsesOutputMixin
from fast_agent.llm.provider.openai.responses_streaming import ResponsesStreamingMixin
from fast_agent.llm.provider.openai.responses_websocket import (
    ManagedWebSocketConnection,
    ResponsesWebSocketError,
    ResponsesWsRequestPlanner,
    StatefulContinuationResponsesWsPlanner,
    WebSocketConnectionManager,
    WebSocketResponsesStream,
    build_ws_headers,
    connect_websocket,
    resolve_responses_ws_url,
    send_response_request,
)
from fast_agent.llm.provider.openai.schema_sanitizer import (
    sanitize_tool_input_schema,
    should_strip_tool_schema_defaults,
)
from fast_agent.llm.provider.openai.streaming_utils import with_stream_idle_timeout
from fast_agent.llm.provider.openai.web_tools import build_web_search_tool, resolve_web_search
from fast_agent.llm.provider_types import Provider
from fast_agent.llm.reasoning_effort import format_reasoning_setting, parse_reasoning_setting
from fast_agent.llm.request_params import RequestParams
from fast_agent.llm.text_verbosity import parse_text_verbosity
from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
from fast_agent.tools.apply_patch_tool import get_openai_responses_custom_tool_payload
from fast_agent.types.llm_stop_reason import LlmStopReason

_logger = get_logger(__name__)

DEFAULT_RESPONSES_MODEL = "gpt-5.2"
DEFAULT_REASONING_EFFORT = "medium"
MIN_RESPONSES_MAX_TOKENS = 16
DEFAULT_RESPONSES_BASE_URL = "https://api.openai.com/v1"
RESPONSES_DIAGNOSTICS_CHANNEL = "fast-agent-provider-diagnostics"
RESPONSE_INCLUDE_REASONING = "reasoning.encrypted_content"
RESPONSE_INCLUDE_WEB_SEARCH_SOURCES = "web_search_call.action.sources"

ResponsesTransport = Literal["sse", "websocket", "auto"]
ResponsesServiceTier = Literal["fast", "flex"]


class ResponsesLLM(
    ResponsesContentMixin,
    ResponsesFileMixin,
    ResponsesOutputMixin,
    ResponsesStreamingMixin,
    FastAgentLLM[dict[str, Any], Any],
):
    """LLM implementation for OpenAI's Responses models."""

    config_section: str | None = None

    RESPONSES_EXCLUDE_FIELDS = {
        FastAgentLLM.PARAM_MESSAGES,
        FastAgentLLM.PARAM_MODEL,
        FastAgentLLM.PARAM_MAX_TOKENS,
        FastAgentLLM.PARAM_SYSTEM_PROMPT,
        FastAgentLLM.PARAM_STOP_SEQUENCES,
        FastAgentLLM.PARAM_USE_HISTORY,
        FastAgentLLM.PARAM_MAX_ITERATIONS,
        FastAgentLLM.PARAM_TEMPLATE_VARS,
        FastAgentLLM.PARAM_MCP_METADATA,
        FastAgentLLM.PARAM_PARALLEL_TOOL_CALLS,
        "response_format",
    }

    def __init__(self, provider: Provider = Provider.RESPONSES, **kwargs) -> None:
        web_search_override = kwargs.pop("web_search", None)
        kwargs.pop("provider", None)
        super().__init__(provider=provider, **kwargs)
        self.logger = get_logger(f"{__name__}.{self.name}" if self.name else __name__)
        self._tool_call_id_map: dict[str, str] = {}
        self._tool_name_map: dict[str, str] = {}
        self._tool_kind_map: dict[str, Literal["function", "custom"]] = {}
        self._seen_tool_call_ids: set[str] = set()
        self._tool_call_diagnostics: dict[str, Any] | None = None
        self._last_ws_request_type: str | None = None
        self._last_ws_request_mode: Literal["create", "continuation"] | None = None
        self._last_ws_turn_outcome: Literal["fresh", "reused", "reconnected"] | None = None
        self._ws_turn_counters: dict[str, int] = {
            "total": 0,
            "fresh": 0,
            "reused": 0,
            "reconnected": 0,
        }
        self._file_id_cache: dict[str, str] = {}
        self._transport: ResponsesTransport = self._default_transport_setting()
        self._last_transport_used: Literal["sse", "websocket"] | None = None
        self._ws_connections = WebSocketConnectionManager(idle_timeout_seconds=300.0)
        self._ws_debug_inline = os.getenv("FAST_AGENT_DEBUG_RESPONSES_WS", "").strip().lower() in {
            "1",
            "true",
            "yes",
            "on",
        }
        self._web_search_override: bool | None = (
            bool(web_search_override) if isinstance(web_search_override, bool) else None
        )

        raw_setting = kwargs.get("reasoning_effort", None)
        settings = self._get_provider_config()
        if settings and raw_setting is None:
            raw_setting = getattr(settings, "reasoning", None)
            if raw_setting is None and hasattr(settings, "reasoning_effort"):
                raw_setting = settings.reasoning_effort
                if (
                    raw_setting is not None
                    and "reasoning_effort" in settings.model_fields_set
                    and settings.reasoning_effort
                    != type(settings).model_fields["reasoning_effort"].default
                ):
                    self.logger.warning(
                        "Responses config 'reasoning_effort' is deprecated; use 'reasoning'."
                    )

        setting = parse_reasoning_setting(raw_setting)
        if setting is not None:
            try:
                self.set_reasoning_effort(setting)
            except ValueError as exc:
                self.logger.warning(f"Invalid reasoning setting: {exc}")

        raw_text_verbosity = kwargs.get("text_verbosity", None)
        if settings and raw_text_verbosity is None:
            raw_text_verbosity = getattr(settings, "text_verbosity", None)
        if raw_text_verbosity is not None:
            parsed_verbosity = parse_text_verbosity(str(raw_text_verbosity))
            if parsed_verbosity is None:
                self.logger.warning(f"Invalid text verbosity setting: {raw_text_verbosity}")
            else:
                try:
                    self.set_text_verbosity(parsed_verbosity)
                except ValueError as exc:
                    self.logger.warning(f"Invalid text verbosity setting: {exc}")

        if self.default_request_params.service_tier is None and settings is not None:
            configured_service_tier = self._normalize_service_tier(
                getattr(settings, "service_tier", None)
            )
            if configured_service_tier is not None:
                self.default_request_params.service_tier = configured_service_tier

        self.default_request_params.service_tier = self._ensure_supported_service_tier(
            self.default_request_params.service_tier,
            source="initial configuration",
        )

        chosen_model = self.default_request_params.model if self.default_request_params else None
        self._reasoning_mode = self._get_model_reasoning(chosen_model)
        self._reasoning = self._reasoning_mode == "openai"
        if self._reasoning_mode:
            self.logger.info(
                f"Using Responses model '{chosen_model}' (mode='{self._reasoning_mode}') with "
                f"'{format_reasoning_setting(self.reasoning_effort)}' reasoning effort"
            )

        self._transport = self._resolve_transport_setting(kwargs.get("transport"), settings)
        self._validate_transport_support(chosen_model, self._transport)

    @property
    def active_transport(self) -> Literal["sse", "websocket"] | None:
        """Return the transport used by the most recent completion call."""
        return self._last_transport_used

    @property
    def configured_transport(self) -> ResponsesTransport:
        """Return configured transport preference for this LLM instance."""
        return self._transport

    @property
    def websocket_turn_indicator(self) -> str | None:
        """Small glyph representing the websocket outcome for the last turn."""
        if self._last_ws_turn_outcome is None:
            return None
        if self._last_ws_turn_outcome == "reconnected":
            return "↗"
        if self._last_ws_turn_outcome == "reused":
            return "↔"
        if self._last_ws_turn_outcome == "fresh":
            return "↗"
        return None

    @property
    def websocket_turn_metrics(self) -> dict[str, int] | None:
        """Cumulative websocket turn counters for this LLM instance."""
        if self._ws_turn_counters["total"] <= 0:
            return None
        return dict(self._ws_turn_counters)

    def _record_ws_turn_outcome(self, outcome: Literal["fresh", "reused", "reconnected"]) -> None:
        self._last_ws_turn_outcome = outcome
        self._ws_turn_counters["total"] += 1
        self._ws_turn_counters[outcome] += 1

    def _websocket_diagnostics_payload(self) -> dict[str, Any]:
        payload: dict[str, Any] = {"transport": self._last_transport_used or "unknown"}
        if self._last_transport_used != "websocket":
            return payload
        if self._last_ws_request_type:
            payload["websocket_request_type"] = self._last_ws_request_type
        if self._last_ws_request_mode is not None:
            payload["websocket_request_mode"] = self._last_ws_request_mode
        if self._last_ws_turn_outcome is not None:
            payload["websocket_turn_outcome"] = self._last_ws_turn_outcome
        if metrics := self.websocket_turn_metrics:
            payload["websocket_turn_metrics"] = metrics
        return payload

    def _parse_service_tier(self, raw_value: Any) -> ResponsesServiceTier | None:
        if raw_value is None:
            return None

        normalized = str(raw_value).strip().lower()
        if normalized == "fast":
            return "fast"
        if normalized == "flex":
            return "flex"

        self.logger.warning(f"Invalid service tier setting: {raw_value}")
        return None

    def _available_service_tiers_for_model(
        self, model_name: str | None
    ) -> tuple[ResponsesServiceTier, ...]:
        if self.provider == Provider.CODEX_RESPONSES:
            return ("fast",)
        if model_name:
            configured_tiers = self._get_model_response_service_tiers(model_name)
            if configured_tiers is not None:
                return configured_tiers
        return ("fast", "flex")

    def _normalize_service_tier(self, raw_value: Any) -> ResponsesServiceTier | None:
        normalized = self._parse_service_tier(raw_value)
        if normalized is None:
            return None
        if normalized not in self.available_service_tiers:
            self.logger.warning(
                f"Service tier '{normalized}' is not supported for provider '{self.provider.value}'."
            )
            return None
        return normalized

    def _ensure_supported_service_tier(
        self,
        service_tier: ResponsesServiceTier | None,
        *,
        source: str,
        model_name: str | None = None,
    ) -> ResponsesServiceTier | None:
        if service_tier is None:
            return None
        available_tiers = self._available_service_tiers_for_model(model_name)
        if service_tier in available_tiers:
            return service_tier
        allowed = ", ".join(available_tiers) or "standard"
        target_model = f" for model '{model_name}'" if model_name else ""
        raise ModelConfigError(
            f"Provider '{self.provider.value}' does not support service tier '{service_tier}' "
            f"from {source}{target_model}. Allowed values: {allowed} or unset (standard)."
        )

    def _resolve_service_tier(
        self,
        request_params: RequestParams,
        model_name: str | None,
    ) -> ResponsesServiceTier | None:
        if "service_tier" in request_params.model_fields_set:
            return self._ensure_supported_service_tier(
                request_params.service_tier,
                source="request params",
                model_name=model_name,
            )
        return self._ensure_supported_service_tier(
            self.service_tier,
            source="provider defaults",
            model_name=model_name,
        )

    @staticmethod
    def _map_service_tier_to_wire_value(service_tier: ResponsesServiceTier) -> str:
        if service_tier == "fast":
            return "priority"
        return "flex"

    def _default_transport_setting(self) -> ResponsesTransport:
        if self.provider in {Provider.RESPONSES, Provider.CODEX_RESPONSES}:
            return "auto"
        return "sse"

    def _resolve_transport_setting(self, raw_value: Any, settings: Any) -> ResponsesTransport:
        value = raw_value
        if value is None and settings is not None:
            model_fields_set = getattr(settings, "model_fields_set", set())
            if "transport" in model_fields_set:
                value = getattr(settings, "transport", None)
        if value is None:
            return self._default_transport_setting()

        normalized = str(value).strip().lower()
        transport_aliases: dict[str, ResponsesTransport] = {
            "ws": "websocket",
            "sse": "sse",
            "websocket": "websocket",
            "auto": "auto",
        }
        normalized_transport = transport_aliases.get(normalized)
        if normalized_transport is not None:
            return normalized_transport

        default_transport = self._default_transport_setting()
        self.logger.warning(
            f"Invalid Responses transport setting; defaulting to {default_transport}",
            data={"transport": value},
        )
        return default_transport

    def _supports_websocket_transport(self) -> bool:
        """Provider-level websocket support flag (opt-in while experimental)."""
        return True

    def _validate_transport_support(
        self,
        model_name: str | None,
        transport: ResponsesTransport,
    ) -> None:
        if transport not in {"websocket", "auto"}:
            return

        model_to_check = model_name or self.default_request_params.model
        if not model_to_check:
            raise ModelConfigError("WebSocket transport requires a resolved model name.")

        if self.provider == Provider.RESPONSES:
            if not self._supports_websocket_transport():
                raise ModelConfigError(
                    "WebSocket transport is experimental and not enabled for this provider."
                )
            return

        response_transports = self._get_model_response_transports(model_to_check)
        if not response_transports or "websocket" not in response_transports:
            raise ModelConfigError(
                f"Transport '{transport}' is not supported for model '{model_to_check}'."
            )
        websocket_providers = self._get_model_response_websocket_providers(model_to_check)
        if websocket_providers is not None and self.provider not in websocket_providers:
            raise ModelConfigError(
                f"Transport '{transport}' is not supported for model '{model_to_check}' "
                f"with provider '{self.provider.value}'."
            )
        if not self._supports_websocket_transport():
            raise ModelConfigError(
                "WebSocket transport is experimental and not enabled for this provider."
            )

    def _effective_transport(self) -> ResponsesTransport:
        return self._transport

    def _base_responses_url(self) -> str:
        return self._base_url() or DEFAULT_RESPONSES_BASE_URL

    def _build_websocket_headers(self) -> dict[str, str]:
        return build_ws_headers(api_key=self._api_key(), default_headers=self._default_headers())

    async def _create_websocket_connection(
        self,
        url: str,
        headers: dict[str, str],
        timeout_seconds: float | None,
    ) -> ManagedWebSocketConnection:
        return await connect_websocket(url=url, headers=headers, timeout_seconds=timeout_seconds)

    def _new_ws_request_planner(self) -> ResponsesWsRequestPlanner:
        return StatefulContinuationResponsesWsPlanner()

    def _websocket_retry_diagnostics(
        self,
        connection: ManagedWebSocketConnection,
        error: ResponsesWebSocketError,
    ) -> dict[str, Any]:
        now = asyncio.get_running_loop().time()
        idle_age_seconds: float | None = None
        if connection.last_used_monotonic > 0.0:
            idle_age_seconds = max(0.0, now - connection.last_used_monotonic)

        websocket = connection.websocket
        close_code = getattr(websocket, "close_code", None)
        exception_obj = websocket.exception()

        diagnostics: dict[str, Any] = {
            "stream_started": error.stream_started,
            "session_closed": connection.session.closed,
            "websocket_closed": websocket.closed,
            "websocket_close_code": close_code,
            "websocket_exception": str(exception_obj) if exception_obj else None,
        }
        if idle_age_seconds is not None:
            diagnostics["idle_age_seconds"] = round(idle_age_seconds, 3)
        return diagnostics

    def _ws_input_count(self, payload: dict[str, Any]) -> int | None:
        input_items = payload.get("input")
        if not isinstance(input_items, list):
            return None
        return len(input_items)

    def _payload_size_bytes(self, payload: dict[str, Any]) -> int | None:
        try:
            compact = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
        except Exception:
            return None
        return len(compact.encode("utf-8"))

    def _report_ws_request_plan(
        self,
        *,
        model_name: str,
        ws_url: str,
        planned_request: Any,
        full_arguments: dict[str, Any],
        reused_existing_connection: bool,
    ) -> None:
        continuation_id = planned_request.arguments.get("previous_response_id")
        request_mode: Literal["create", "continuation"] = (
            "continuation" if isinstance(continuation_id, str) and continuation_id else "create"
        )
        self._last_ws_request_mode = request_mode
        sent_input_count = self._ws_input_count(planned_request.arguments)
        full_input_count = self._ws_input_count(full_arguments)
        sent_payload_bytes = self._payload_size_bytes(planned_request.arguments)
        full_payload_bytes = self._payload_size_bytes(full_arguments)
        payload_saved_bytes: int | None = None
        payload_saved_ratio: float | None = None
        if sent_payload_bytes is not None and full_payload_bytes and full_payload_bytes > 0:
            payload_saved_bytes = max(0, full_payload_bytes - sent_payload_bytes)
            payload_saved_ratio = payload_saved_bytes / full_payload_bytes

        self.logger.info(
            "Responses websocket request plan",
            data={
                "model": model_name,
                "url": ws_url,
                "request_type": planned_request.event_type,
                "request_mode": request_mode,
                "reused_connection": reused_existing_connection,
                "sent_input_items": sent_input_count,
                "total_input_items": full_input_count,
                "sent_payload_bytes": sent_payload_bytes,
                "total_payload_bytes": full_payload_bytes,
                "payload_saved_bytes": payload_saved_bytes,
                "payload_saved_ratio": payload_saved_ratio,
                "uses_previous_response_id": request_mode == "continuation",
                "previous_response_id": continuation_id if request_mode == "continuation" else None,
            },
        )

        if not self._ws_debug_inline:
            return

        try:
            from rich.text import Text

            item_counts = ""
            if sent_input_count is not None and full_input_count is not None:
                item_counts = f" {sent_input_count}/{full_input_count} items"
            byte_counts = ""
            if sent_payload_bytes is not None and full_payload_bytes is not None:
                if payload_saved_bytes is not None and payload_saved_ratio is not None:
                    percent_saved = round(payload_saved_ratio * 100.0)
                    byte_counts = (
                        f" {sent_payload_bytes}/{full_payload_bytes}B"
                        f" ({percent_saved}% saved)"
                    )
                else:
                    byte_counts = f" {sent_payload_bytes}/{full_payload_bytes}B"
            prev_suffix = f" prev={continuation_id}" if request_mode == "continuation" else ""
            reuse_suffix = " reused-conn" if reused_existing_connection else ""
            self.display.show_status_message(
                Text.from_markup(
                    f"[dim]WS {request_mode}{item_counts}{byte_counts}{prev_suffix}{reuse_suffix}[/dim]"
                )
            )
        except Exception:
            # UI status notification should never affect completion flow.
            pass

    def _resolve_reasoning_effort(self) -> str | None:
        setting = self.reasoning_effort
        if setting is None:
            return DEFAULT_REASONING_EFFORT
        if setting.kind == "effort":
            return str(setting.value)
        if setting.kind == "toggle":
            return None if setting.value is False else DEFAULT_REASONING_EFFORT
        if setting.kind == "budget":
            self.logger.warning("Ignoring budget reasoning setting for Responses models.")
            return DEFAULT_REASONING_EFFORT
        return DEFAULT_REASONING_EFFORT

    def _initialize_default_params(self, kwargs: dict[str, Any]) -> RequestParams:
        return self._initialize_default_params_with_model_fallback(kwargs, DEFAULT_RESPONSES_MODEL)

    def _provider_config_fallback_sections(self) -> tuple[str, ...]:
        return ("openai",)

    def _openai_settings(self):
        return self._get_provider_config()

    @property
    def web_search_supported(self) -> bool:
        """Responses-family models currently expose the web_search server tool."""
        return True

    @property
    def web_search_enabled(self) -> bool:
        """Whether Responses web_search is enabled for this LLM instance."""
        resolved_web_search = resolve_web_search(
            self._openai_settings(),
            web_search_override=self._web_search_override,
        )
        return resolved_web_search.enabled

    def set_web_search_enabled(self, value: bool | None) -> None:
        self._web_search_override = value

    @property
    def web_fetch_supported(self) -> bool:
        """Responses-family models do not expose web_fetch."""
        return False

    @property
    def web_fetch_enabled(self) -> bool:
        """Responses-family models do not expose web_fetch."""
        return False

    def set_web_fetch_enabled(self, value: bool | None) -> None:
        super().set_web_fetch_enabled(value)

    @property
    def service_tier_supported(self) -> bool:
        """Responses-family models expose service tier selection."""
        return bool(self.available_service_tiers)

    @property
    def available_service_tiers(self) -> tuple[ResponsesServiceTier, ...]:
        return self._available_service_tiers_for_model(self.default_request_params.model)

    @property
    def service_tier(self) -> ResponsesServiceTier | None:
        return self.default_request_params.service_tier

    def set_service_tier(self, value: ResponsesServiceTier | None) -> None:
        parsed_value = self._parse_service_tier(value)
        if value is not None and parsed_value is None:
            raise ValueError("Current model does not support the requested service tier.")
        if parsed_value is not None and parsed_value not in self.available_service_tiers:
            allowed = ", ".join(self.available_service_tiers) or "standard"
            raise ValueError(
                f"Current model supports only {allowed} or unset (standard) service tier."
            )
        self.default_request_params.service_tier = parsed_value

    def _provider_base_url(self) -> str | None:
        settings = self._openai_settings()
        return settings.base_url if settings else None

    def _provider_default_headers(self) -> dict[str, str] | None:
        settings = self._openai_settings()
        return settings.default_headers if settings else None

    def _responses_client(self) -> AsyncOpenAI:
        try:
            kwargs: dict[str, Any] = {
                "api_key": self._api_key(),
                "base_url": self._base_url(),
                "http_client": DefaultAioHttpClient(),
            }
            default_headers = self._default_headers()
            if default_headers:
                kwargs["default_headers"] = default_headers
            return AsyncOpenAI(**kwargs)
        except AuthenticationError as e:
            raise ProviderKeyError(
                "Invalid OpenAI API key",
                "The configured OpenAI API key was rejected.\n"
                "Please check that your API key is valid and not expired.",
            ) from e

    def _adjust_schema(self, input_schema: dict[str, Any], model_name: str) -> dict[str, Any]:
        result = (
            sanitize_tool_input_schema(input_schema)
            if should_strip_tool_schema_defaults(model_name)
            else input_schema
        )
        if "properties" in result:
            return result
        result = result.copy()
        result["properties"] = {}
        return result

    async def _apply_prompt_provider_specific(
        self,
        multipart_messages: list[PromptMessageExtended],
        request_params: RequestParams | None = None,
        tools: list[Tool] | None = None,
        is_template: bool = False,
    ) -> PromptMessageExtended:
        req_params = self.get_request_params(request_params)

        last_message = multipart_messages[-1]
        if last_message.role == "assistant":
            return last_message

        input_items = self._convert_to_provider_format(multipart_messages)
        if not input_items:
            input_items = [
                {
                    "type": "message",
                    "role": "user",
                    "content": [{"type": "input_text", "text": ""}],
                }
            ]

        return await self._responses_completion(input_items, req_params, tools)

    def _build_response_args(
        self,
        input_items: list[dict[str, Any]],
        request_params: RequestParams,
        tools: list[Tool] | None,
    ) -> dict[str, Any]:
        model = request_params.model or self.default_request_params.model or DEFAULT_RESPONSES_MODEL
        base_args: dict[str, Any] = {
            "model": model,
            "input": input_items,
            "store": False,
            "include": [RESPONSE_INCLUDE_REASONING],
            "parallel_tool_calls": request_params.parallel_tool_calls,
        }

        system_prompt = self.instruction or request_params.systemPrompt
        if system_prompt:
            base_args["instructions"] = system_prompt

        if tools:
            tools_payload: list[dict[str, Any]] = []
            for tool in tools:
                custom_payload = get_openai_responses_custom_tool_payload(tool)
                if custom_payload is not None:
                    tools_payload.append(custom_payload)
                    continue

                tools_payload.append(
                    {
                        "type": "function",
                        "name": tool.name,
                        "description": tool.description or "",
                        "parameters": self._adjust_schema(tool.inputSchema, model),
                    }
                )
            base_args["tools"] = tools_payload

        resolved_web_search = resolve_web_search(
            self._openai_settings(),
            web_search_override=self._web_search_override,
        )
        web_search_tool = build_web_search_tool(resolved_web_search)
        if web_search_tool is not None:
            tools_payload = base_args.setdefault("tools", [])
            if isinstance(tools_payload, list):
                tools_payload.append(web_search_tool)

            include_payload = base_args.get("include")
            if isinstance(include_payload, list):
                if RESPONSE_INCLUDE_WEB_SEARCH_SOURCES not in include_payload:
                    include_payload.append(RESPONSE_INCLUDE_WEB_SEARCH_SOURCES)

        if self._reasoning:
            effort = self._resolve_reasoning_effort()
            if effort:
                base_args["reasoning"] = {
                    "summary": "auto",
                    "effort": effort,
                }

        if request_params.maxTokens is not None:
            max_tokens = request_params.maxTokens
            if max_tokens < MIN_RESPONSES_MAX_TOKENS:
                self.logger.debug(
                    "Clamping max_output_tokens to Responses minimum",
                    data={
                        "requested": max_tokens,
                        "minimum": MIN_RESPONSES_MAX_TOKENS,
                    },
                )
                max_tokens = MIN_RESPONSES_MAX_TOKENS
            base_args["max_output_tokens"] = max_tokens

        if request_params.response_format:
            base_args["text"] = {
                "format": self._normalize_text_format(request_params.response_format)
            }

        text_verbosity_spec = self.text_verbosity_spec
        if text_verbosity_spec:
            text_payload = base_args.get("text")
            if not isinstance(text_payload, dict):
                text_payload = {}
            text_payload["verbosity"] = self.text_verbosity or text_verbosity_spec.default
            base_args["text"] = text_payload

        service_tier = self._resolve_service_tier(request_params, model)
        if service_tier is not None:
            base_args["service_tier"] = self._map_service_tier_to_wire_value(service_tier)

        return self.prepare_provider_arguments(
            base_args, request_params, self.RESPONSES_EXCLUDE_FIELDS
        )

    async def _responses_completion(
        self,
        input_items: list[dict[str, Any]],
        request_params: RequestParams,
        tools: list[Tool] | None = None,
    ) -> PromptMessageExtended:
        model_name = (
            request_params.model or self.default_request_params.model or DEFAULT_RESPONSES_MODEL
        )
        transport = self._effective_transport()
        self._validate_transport_support(model_name, transport)

        display_model = model_name
        if transport in {"websocket", "auto"}:
            display_model = f"{model_name} [ws]"

        self._log_chat_progress(self.chat_turn(), model=display_model)
        self._last_ws_request_type = None
        self._last_ws_request_mode = None
        self._last_ws_turn_outcome = None

        try:
            if transport == "sse":
                response, streamed_summary, input_items = await self._responses_completion_sse(
                    input_items=input_items,
                    request_params=request_params,
                    tools=tools,
                    model_name=model_name,
                )
                self._last_transport_used = "sse"
            else:
                response, streamed_summary, input_items = await self._responses_completion_ws(
                    input_items=input_items,
                    request_params=request_params,
                    tools=tools,
                    model_name=model_name,
                )
                self._last_transport_used = "websocket"
        except ResponsesWebSocketError as error:
            should_fallback_to_sse = transport == "auto" and not error.stream_started
            if should_fallback_to_sse:
                self.logger.warning(
                    "WebSocket transport failed before stream start; falling back to SSE "
                    "(auto transport safeguard)",
                    data={
                        "model": model_name,
                        "requested_transport": transport,
                        "error": str(error),
                    },
                )
                try:
                    from rich.text import Text

                    self.display.show_status_message(
                        Text.from_markup(
                            "[yellow]⚠ WebSocket transport unavailable for this turn; using SSE fallback.[/yellow]"
                        )
                    )
                except Exception:
                    # UI status notification should never affect completion flow.
                    pass
                response, streamed_summary, input_items = await self._responses_completion_sse(
                    input_items=input_items,
                    request_params=request_params,
                    tools=tools,
                    model_name=model_name,
                )
                self._last_transport_used = "sse"
            else:
                raise
        except asyncio.CancelledError:
            return Prompt.assistant(
                TextContent(type="text", text=""),
                stop_reason=LlmStopReason.CANCELLED,
            )

        if response is None:
            raise RuntimeError("Responses stream did not return a final response")

        self._log_chat_finished(model=model_name)

        response_content_blocks: list[ContentBlock] = []
        channels: dict[str, list[ContentBlock]] | None = None
        reasoning_blocks = self._extract_reasoning_summary(response, streamed_summary)
        encrypted_blocks = self._extract_encrypted_reasoning(response)
        if reasoning_blocks or encrypted_blocks:
            channels = {}
            if reasoning_blocks:
                channels[REASONING] = reasoning_blocks
            if encrypted_blocks:
                channels[OPENAI_REASONING_ENCRYPTED] = encrypted_blocks

        tool_calls = self._extract_tool_calls(response)
        assistant_message_items, message_phase = self._extract_raw_assistant_message_items(response)
        if assistant_message_items:
            if channels is None:
                channels = {}
            channels[OPENAI_ASSISTANT_MESSAGE_ITEMS] = assistant_message_items
        tool_call_diagnostics = self._consume_tool_call_diagnostics()
        diagnostics_payload = dict(tool_call_diagnostics) if tool_call_diagnostics else None
        websocket_diagnostics = self._websocket_diagnostics_payload()
        if diagnostics_payload is not None:
            diagnostics_payload.update(websocket_diagnostics)
        elif self._last_transport_used == "websocket":
            diagnostics_payload = websocket_diagnostics

        if diagnostics_payload:
            if channels is None:
                channels = {}
            channels[RESPONSES_DIAGNOSTICS_CHANNEL] = [
                TextContent(type="text", text=json.dumps(diagnostics_payload))
            ]
        if tool_calls:
            stop_reason = LlmStopReason.TOOL_USE
        else:
            stop_reason = self._map_response_stop_reason(response)

        for output_item in getattr(response, "output", []) or []:
            if getattr(output_item, "type", None) != "message":
                continue
            for part in getattr(output_item, "content", []) or []:
                if getattr(part, "type", None) == "output_text":
                    response_content_blocks.append(
                        TextContent(type="text", text=getattr(part, "text", ""))
                    )

        if not response_content_blocks:
            output_text = getattr(response, "output_text", None)
            if output_text:
                response_content_blocks.append(TextContent(type="text", text=output_text))

        if getattr(response, "usage", None):
            self._record_usage(response.usage, model_name)

        web_tool_payloads, citation_payloads = self._extract_web_search_metadata(response)
        if web_tool_payloads:
            if channels is None:
                channels = {}
            channels[ANTHROPIC_SERVER_TOOLS_CHANNEL] = web_tool_payloads
        if citation_payloads:
            if channels is None:
                channels = {}
            channels[ANTHROPIC_CITATIONS_CHANNEL] = citation_payloads

        self.history.set(input_items)

        return PromptMessageExtended(
            role="assistant",
            content=response_content_blocks,
            tool_calls=tool_calls,
            channels=channels,
            stop_reason=stop_reason,
            phase=message_phase,
        )

    async def _responses_completion_sse(
        self,
        *,
        input_items: list[dict[str, Any]],
        request_params: RequestParams,
        tools: list[Tool] | None,
        model_name: str,
    ) -> tuple[Any, list[str], list[dict[str, Any]]]:
        try:
            async with self._responses_client() as client:
                normalized_input = await self._normalize_input_files(client, input_items)
                arguments = self._build_response_args(normalized_input, request_params, tools)
                self.logger.debug("Responses request", data=arguments)
                capture_filename = _stream_capture_filename(self.chat_turn())
                _save_stream_request(capture_filename, arguments)
                async with self._response_sse_stream(client=client, arguments=arguments) as stream:
                    timeout = request_params.streaming_timeout
                    timed_stream = with_stream_idle_timeout(
                        stream,
                        idle_timeout_seconds=timeout,
                        timeout_message=f"Streaming was idle for more than {timeout} seconds.",
                    )
                    try:
                        response, streamed_summary = await self._process_stream(
                            timed_stream, model_name, capture_filename
                        )
                    except TimeoutError:
                        if timeout is None:
                            raise
                        self.logger.error(
                            "Streaming idle timeout while waiting for Responses",
                            data={
                                "model": model_name,
                                "timeout_seconds": timeout,
                            },
                        )
                        raise
                return response, streamed_summary, normalized_input
        except AuthenticationError as e:
            raise ProviderKeyError(
                "Invalid OpenAI API key",
                "The configured OpenAI API key was rejected.\n"
                "Please check that your API key is valid and not expired.",
            ) from e
        except APIError as error:
            self.logger.error("Streaming APIError during Responses completion", exc_info=error)
            raise

    @asynccontextmanager
    async def _response_sse_stream(
        self,
        *,
        client: AsyncOpenAI,
        arguments: dict[str, Any],
    ):
        async with client.responses.stream(**arguments) as stream:
            yield stream

    async def _responses_completion_ws(
        self,
        *,
        input_items: list[dict[str, Any]],
        request_params: RequestParams,
        tools: list[Tool] | None,
        model_name: str,
    ) -> tuple[Any, list[str], list[dict[str, Any]]]:
        async with self._responses_client() as client:
            normalized_input = await self._normalize_input_files(client, input_items)

        arguments = self._build_response_args(normalized_input, request_params, tools)
        self.logger.debug("Responses websocket request", data=arguments)
        capture_filename = _stream_capture_filename(self.chat_turn())
        _save_stream_request(capture_filename, arguments)

        ws_url = resolve_responses_ws_url(self._base_responses_url())
        ws_headers = self._build_websocket_headers()
        timeout = request_params.streaming_timeout

        async def _create_connection() -> ManagedWebSocketConnection:
            return await self._create_websocket_connection(ws_url, ws_headers, timeout)

        last_error: ResponsesWebSocketError | None = None
        reconnected = False
        for attempt in range(2):
            connection, is_reusable = await self._ws_connections.acquire(_create_connection)
            reused_existing_connection = is_reusable and connection.last_used_monotonic > 0.0
            planner = connection.session_state.request_planner
            if planner is None:
                planner = self._new_ws_request_planner()
                connection.session_state.request_planner = planner
            keep_connection = False
            stream: WebSocketResponsesStream | None = None
            retry_after_release = False
            reconnect_diagnostics: dict[str, Any] | None = None

            try:
                self.logger.info(
                    "Using Responses websocket transport",
                    data={"model": model_name, "url": ws_url},
                )
                planned_request = planner.plan(arguments)
                self._last_ws_request_type = planned_request.event_type
                self._report_ws_request_plan(
                    model_name=model_name,
                    ws_url=ws_url,
                    planned_request=planned_request,
                    full_arguments=arguments,
                    reused_existing_connection=reused_existing_connection,
                )
                await send_response_request(connection.websocket, planned_request)
                stream = WebSocketResponsesStream(connection.websocket)
                timed_stream = with_stream_idle_timeout(
                    stream,
                    idle_timeout_seconds=timeout,
                    timeout_message=f"Streaming was idle for more than {timeout} seconds.",
                )
                try:
                    response, streamed_summary = await self._process_stream(
                        timed_stream, model_name, capture_filename
                    )
                except TimeoutError:
                    if timeout is None:
                        raise
                    self.logger.error(
                        "Streaming idle timeout while waiting for Responses websocket",
                        data={
                            "model": model_name,
                            "timeout_seconds": timeout,
                        },
                    )
                    raise
                planner.commit(arguments, planned_request, response)
                keep_connection = True
                if reconnected:
                    self._record_ws_turn_outcome("reconnected")
                elif reused_existing_connection:
                    self._record_ws_turn_outcome("reused")
                else:
                    self._record_ws_turn_outcome("fresh")
                return response, streamed_summary, normalized_input
            except ResponsesWebSocketError as error:
                planner.rollback(error, stream_started=error.stream_started)
                last_error = error
                retry_after_release = (
                    attempt == 0
                    and not error.stream_started
                    and (
                        reused_existing_connection
                        or error.error_code
                        in {
                            "previous_response_not_found",
                            "websocket_connection_limit_reached",
                        }
                    )
                )
                if retry_after_release:
                    reconnect_diagnostics = self._websocket_retry_diagnostics(connection, error)
                if not retry_after_release:
                    raise
            except TimeoutError as error:
                planner.rollback(
                    error,
                    stream_started=stream.stream_started if stream is not None else False,
                )
                raise
            except Exception as exc:
                stream_started = stream.stream_started if stream is not None else False
                planner.rollback(exc, stream_started=stream_started)
                wrapped_error = ResponsesWebSocketError(
                    str(exc),
                    stream_started=stream_started,
                )
                last_error = wrapped_error
                retry_after_release = (
                    attempt == 0 and not stream_started and reused_existing_connection
                )
                if retry_after_release:
                    reconnect_diagnostics = self._websocket_retry_diagnostics(
                        connection,
                        wrapped_error,
                    )
                if not retry_after_release:
                    raise wrapped_error from exc
            finally:
                if not (is_reusable and keep_connection):
                    planner.reset()
                    connection.session_state.request_planner = None
                await self._ws_connections.release(
                    connection,
                    reusable=is_reusable,
                    keep=keep_connection,
                )

            if retry_after_release:
                reconnected = True
                retry_data: dict[str, Any] = {
                    "model": model_name,
                    "url": ws_url,
                    "attempt": attempt + 1,
                    "error": str(last_error) if last_error else None,
                }
                if reconnect_diagnostics is not None:
                    retry_data.update(reconnect_diagnostics)
                self.logger.info(
                    "Reusable Responses websocket connection unavailable; re-establishing connection",
                    data=retry_data,
                )
                continue

        if last_error is not None:
            raise last_error

        raise ResponsesWebSocketError(
            "WebSocket transport failed without an explicit error.",
            stream_started=False,
        )

    def _handle_retry_failure(self, error: Exception) -> PromptMessageExtended | None:
        """Return the legacy error-channel response when retries are exhausted."""
        if isinstance(error, APIError):
            model_name = self.default_request_params.model or DEFAULT_RESPONSES_MODEL
            return build_stream_failure_response(self.provider, error, model_name)
        return None

    async def close(self) -> None:
        """Release long-lived websocket resources used by Responses transport."""

        await self._ws_connections.close()

    def clear(self, *, clear_prompts: bool = False) -> None:
        super().clear(clear_prompts=clear_prompts)
        self._tool_call_id_map.clear()
        self._tool_kind_map.clear()
        self._seen_tool_call_ids.clear()
        self._tool_call_diagnostics = None
        self._last_ws_request_type = None
        self._last_ws_request_mode = None
        self._last_ws_turn_outcome = None
        self._ws_turn_counters = {
            "total": 0,
            "fresh": 0,
            "reused": 0,
            "reconnected": 0,
        }
