from __future__ import annotations import asyncio import json import os from collections.abc import AsyncGenerator from dataclasses import dataclass from typing import Any, Literal, cast import aiohttp import httpx import openai from livekit.agents import APIConnectionError, APIStatusError, APITimeoutError, llm, utils from livekit.agents.inference.llm import drop_unsupported_params from livekit.agents.llm import ToolChoice from livekit.agents.llm.chat_context import ChatContext, ChatItem from livekit.agents.llm.tool_context import ( Tool, ) from livekit.agents.types import ( DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr, ) from livekit.agents.utils import is_given from openai.types import Reasoning from openai.types.responses import ( ResponseCompletedEvent, ResponseCreatedEvent, ResponseErrorEvent, ResponseFailedEvent, ResponseInputParam, ResponseOutputItemDoneEvent, ResponseOutputMessage, ResponseTextDeltaEvent, ToolParam, response_create_params, ) from openai.types.responses.response_stream_event import ResponseStreamEvent from openai.types.shared_params import ResponsesModel from ..log import logger from ..models import _supports_reasoning_effort from ..tools import OpenAITool ServiceTier = Literal["auto", "default", "flex", "scale", "priority"] Verbosity = Literal["low", "medium", "high"] OPENAI_RESPONSES_WS_URL = "wss://api.openai.com/v1/responses" class _ResponsesWebsocket: def __init__( self, api_key: str | None, timeout: float | None, base_url: str | None = None ) -> None: self._api_key = api_key self._timeout = timeout or DEFAULT_API_CONNECT_OPTIONS.timeout self._base_url = base_url if base_url else OPENAI_RESPONSES_WS_URL self._session: aiohttp.ClientSession | None = None self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse]( connect_cb=self._create_ws, close_cb=self._close_ws, max_session_duration=3600, ) def _ensure_http_session(self) -> aiohttp.ClientSession: if self._session is None: self._session = utils.http_context.http_session() return self._session async def _create_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse: try: return await asyncio.wait_for( self._ensure_http_session().ws_connect( self._base_url, headers={"Authorization": f"Bearer {self._api_key}"}, ), timeout, ) except aiohttp.ClientError as e: raise APIConnectionError("failed to connect to OpenAI Responses WebSocket") from e except asyncio.TimeoutError as e: raise APIConnectionError("timed out connecting to OpenAI Responses WebSocket") from e async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None: await ws.close() async def aclose(self) -> None: await self._pool.aclose() async def generate_response(self, msg: dict) -> AsyncGenerator[dict, None]: def _default(o: object) -> object: if isinstance(o, openai.BaseModel): # exclude_none is load-bearing, not cosmetic. This hand-rolled WS # transport serializes request models itself instead of going # through the openai SDK (which omits unset fields). Without # exclude_none, every Optional field the model defaults to None # is emitted as an explicit `null` on the wire. The Responses API # rejects explicit nulls on fields that expect an enum: e.g. after # openai-python added `Reasoning.mode` (default None), a plain # `Reasoning(effort=...)` began serializing `"mode": null`, which # the API 400s with "Invalid type for 'reasoning.mode': expected # one of 'standard' or 'pro', but got null instead." Omitting None # mirrors the SDK's on-the-wire shape and is forward-compatible # with future Optional additions to these models. return o.model_dump(mode="json", exclude_none=True) raise TypeError(f"unexpected type {type(o)}") try: data = json.dumps(msg, default=_default) except TypeError as e: raise APIConnectionError(f"failed to serialize request: {e}") from e async with self._pool.connection(timeout=self._timeout) as ws: try: await ws.send_str(data) except Exception as e: raise APIConnectionError("failed to send request over WebSocket") from e while True: raw_msg = await ws.receive() if raw_msg.type == aiohttp.WSMsgType.ERROR: exc = raw_msg.data status_code = exc.status if isinstance(exc, aiohttp.ClientResponseError) else -1 raise APIStatusError( str(exc), status_code=status_code, retryable=False ) from exc if raw_msg.type in ( aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, ): raise APIStatusError( "OpenAI Responses WebSocket connection closed unexpectedly", status_code=raw_msg.data or -1, body=f"{raw_msg.data=} {raw_msg.extra=}", ) if raw_msg.type != aiohttp.WSMsgType.TEXT: continue event = json.loads(raw_msg.data) yield event if event["type"] in ["response.completed", "response.failed", "error"]: return @dataclass class _LLMOptions: model: str | ResponsesModel user: NotGivenOr[str] temperature: NotGivenOr[float] parallel_tool_calls: NotGivenOr[bool] tool_choice: NotGivenOr[ToolChoice | Literal["auto", "required", "none"]] store: NotGivenOr[bool] reasoning: NotGivenOr[Reasoning] metadata: NotGivenOr[dict[str, str]] service_tier: NotGivenOr[ServiceTier] verbosity: NotGivenOr[Verbosity] max_output_tokens: NotGivenOr[int] use_websocket: bool class LLM(llm.LLM): # the plugin's ProviderTool subclass; subclasses (e.g. xAI) override this so server-side # provider tools are recognized when serializing the request. See to_responses_fnc_ctx. _provider_tool_type: type[llm.ProviderTool] = OpenAITool def __init__( self, *, model: str | ResponsesModel = "gpt-4.1", api_key: NotGivenOr[str] = NOT_GIVEN, base_url: NotGivenOr[str] = NOT_GIVEN, client: openai.AsyncClient | None = None, use_websocket: bool = True, user: NotGivenOr[str] = NOT_GIVEN, temperature: NotGivenOr[float] = NOT_GIVEN, parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN, reasoning: NotGivenOr[Reasoning] = NOT_GIVEN, tool_choice: NotGivenOr[ToolChoice | Literal["auto", "required", "none"]] = NOT_GIVEN, store: NotGivenOr[bool] = NOT_GIVEN, metadata: NotGivenOr[dict[str, str]] = NOT_GIVEN, service_tier: NotGivenOr[ServiceTier] = NOT_GIVEN, verbosity: NotGivenOr[Verbosity] = NOT_GIVEN, max_output_tokens: NotGivenOr[int] = NOT_GIVEN, timeout: httpx.Timeout | None = None, ) -> None: """ Create a new instance of OpenAI Responses LLM. ``api_key`` must be set to your OpenAI API key, either using the argument or by setting the ``OPENAI_API_KEY`` environmental variable. """ super().__init__() if not is_given(reasoning) and _supports_reasoning_effort(model): if model in ["gpt-5.1", "gpt-5.2", "gpt-5.4", "gpt-5.4-mini"]: reasoning = Reasoning(effort="none") else: reasoning = Reasoning(effort="minimal") if client is not None and use_websocket: logger.warning("use_websocket is ignored when a custom client is provided, disabling") use_websocket = False self._opts = _LLMOptions( model=model, user=user, temperature=temperature, parallel_tool_calls=parallel_tool_calls, tool_choice=tool_choice, store=store, metadata=metadata, reasoning=reasoning, service_tier=service_tier, verbosity=verbosity, max_output_tokens=max_output_tokens, use_websocket=use_websocket, ) self._client = client self._owns_client = client is None self._ws: _ResponsesWebsocket | None = None self._active_streams: int = 0 self._parallel_generation: bool = False self._prev_resp_id = "" self._prev_chat_ctx: ChatContext | None = None self._pending_tool_calls = set[str]() # tool call ids that are pending for a response if use_websocket: resolved_api_key = api_key if is_given(api_key) else os.environ.get("OPENAI_API_KEY") if not resolved_api_key: raise ValueError( "OpenAI API key is required, either as argument or set" " OPENAI_API_KEY environment variable" ) self._ws = _ResponsesWebsocket( api_key=resolved_api_key, timeout=timeout.connect if timeout is not None else None, base_url=base_url if is_given(base_url) else None, ) else: self._client = client or openai.AsyncClient( api_key=api_key if is_given(api_key) else None, base_url=base_url if is_given(base_url) else None, max_retries=0, http_client=httpx.AsyncClient( timeout=timeout if timeout else httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0), follow_redirects=True, limits=httpx.Limits( max_connections=50, max_keepalive_connections=50, keepalive_expiry=120, ), ), ) async def aclose(self) -> None: if self._ws: await self._ws.aclose() if self._owns_client and self._client: await self._client.close() @property def model(self) -> str: return self._opts.model @property def provider(self) -> str: if self._opts.use_websocket and self._ws is not None: from urllib.parse import urlparse return urlparse(self._ws._base_url).netloc if self._client is not None: return self._client._base_url.netloc.decode("utf-8") return "" def chat( self, *, chat_ctx: ChatContext, tools: list[Tool] | None = None, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN, tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN, extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN, ) -> LLMStream: extra = {} if is_given(extra_kwargs): extra.update(extra_kwargs) if is_given(self._opts.metadata): extra["metadata"] = self._opts.metadata if is_given(self._opts.user): extra["user"] = self._opts.user if is_given(self._opts.temperature): extra["temperature"] = self._opts.temperature if is_given(self._opts.store): extra["store"] = self._opts.store if is_given(self._opts.reasoning): extra["reasoning"] = self._opts.reasoning if is_given(self._opts.service_tier): extra["service_tier"] = self._opts.service_tier if is_given(self._opts.verbosity): text_cfg = extra.get("text") or {} extra["text"] = {**text_cfg, "verbosity": self._opts.verbosity} if is_given(self._opts.max_output_tokens): extra["max_output_tokens"] = self._opts.max_output_tokens parallel_tool_calls = ( parallel_tool_calls if is_given(parallel_tool_calls) else self._opts.parallel_tool_calls ) if is_given(parallel_tool_calls): extra["parallel_tool_calls"] = parallel_tool_calls tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice if is_given(tool_choice): oai_tool_choice: response_create_params.ToolChoice if isinstance(tool_choice, dict): oai_tool_choice = { "type": "function", "name": tool_choice["function"]["name"], } extra["tool_choice"] = oai_tool_choice elif tool_choice in ("auto", "required", "none"): oai_tool_choice = tool_choice extra["tool_choice"] = oai_tool_choice input_chat_ctx = chat_ctx if ( self._opts.store is not False and self._active_streams == 0 and self._prev_chat_ctx is not None and self._prev_resp_id ): n = len(self._prev_chat_ctx.items) if ChatContext(items=chat_ctx.items[:n]).is_equivalent( self._prev_chat_ctx ) and self._pending_tool_calls_completed(chat_ctx.items[n:]): # send only the new items appended since the last response input_chat_ctx = ChatContext(items=chat_ctx.items[n:]) extra["previous_response_id"] = self._prev_resp_id # if the context was modified otherwise, resend the whole context and omit previous response id return LLMStream( self, model=self._opts.model, strict_tool_schema=True, client=self._client if self._client else None, chat_ctx=input_chat_ctx, tools=tools or [], conn_options=conn_options, extra_kwargs=extra, full_chat_ctx=chat_ctx, ) def _pending_tool_calls_completed(self, items: list[ChatItem]) -> bool: if not self._pending_tool_calls: return True completed_tool_calls = { item.call_id for item in items if item.type == "function_call_output" } return all(call_id in completed_tool_calls for call_id in self._pending_tool_calls) class LLMStream(llm.LLMStream): def __init__( self, llm: LLM, *, model: str | ResponsesModel, strict_tool_schema: bool, client: openai.AsyncClient | None, chat_ctx: llm.ChatContext, tools: list[Tool], conn_options: APIConnectOptions, extra_kwargs: dict[str, Any], full_chat_ctx: llm.ChatContext, ) -> None: super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options) self._model = model self._strict_tool_schema = strict_tool_schema self._response_id: str = "" self._response_completed: bool = False self._pending_tool_calls = set[str]() self._client = client self._llm: LLM = llm self._extra_kwargs = drop_unsupported_params(model, extra_kwargs) self._full_chat_ctx = full_chat_ctx.copy() async def _run(self) -> None: if self._llm._active_streams > 0: self._llm._parallel_generation = True self._llm._active_streams += 1 try: await self._run_impl() finally: self._llm._active_streams -= 1 if self._llm._active_streams == 0 and self._llm._parallel_generation: self._llm._prev_resp_id = "" self._llm._prev_chat_ctx = None self._llm._parallel_generation = False async def _run_impl(self) -> None: self._response_completed = False chat_ctx, _ = self._chat_ctx.to_provider_format(format="openai.responses") self._tool_ctx = llm.ToolContext(self.tools) tool_schemas = cast( list[ToolParam], self._tool_ctx.parse_function_tools( "openai.responses", strict=self._strict_tool_schema, provider_tool_type=self._llm._provider_tool_type, ), ) if self._llm._opts.use_websocket is not False: retryable = True try: if self._llm._ws is None: raise RuntimeError("use_websocket is True but _ws is None") payload = { "type": "response.create", "model": self._model, "input": chat_ctx, "tools": tool_schemas, **self._extra_kwargs, } async for raw_event in self._llm._ws.generate_response(payload): parsed_ev = self._parse_ws_event(raw_event) self._process_event(parsed_ev) retryable = False if not self._response_completed: raise APIConnectionError(retryable=True) except (APIConnectionError, APIStatusError, APITimeoutError): raise except Exception as e: raise APIConnectionError(retryable=retryable) from e else: self._oai_stream: openai.AsyncStream[ResponseStreamEvent] | None = None retryable = True try: self._oai_stream = stream = cast( openai.AsyncStream[ResponseStreamEvent], await self._client.responses.create( # type: ignore model=self._model, tools=tool_schemas, input=cast(str | ResponseInputParam | openai.Omit, chat_ctx), stream=True, timeout=httpx.Timeout(self._conn_options.timeout), **self._extra_kwargs, ), ) async with stream: async for event in stream: self._process_event(event) retryable = False except openai.APITimeoutError: raise APITimeoutError(retryable=retryable) # noqa: B904 except openai.APIStatusError as e: raise APIStatusError( # noqa: B904 e.message, status_code=e.status_code, request_id=e.request_id, body=e.body, retryable=retryable, ) except (APIConnectionError, APIStatusError, APITimeoutError): raise except Exception as e: raise APIConnectionError(retryable=retryable) from e def _parse_ws_event(self, event: dict) -> ResponseStreamEvent | None: # Strip prompt_cache_retention from any response object before validation: # the OpenAI SDK Pydantic type doesn't match actual API values (e.g. "in_memory" # vs "in-memory"). We don't use this field so dropping it is safe. if ( isinstance(event.get("response"), dict) and "prompt_cache_retention" in event["response"] ): event = { **event, "response": { k: v for k, v in event["response"].items() if k != "prompt_cache_retention" }, } event_type = event.get("type", "") if event_type == "error": # Top-level protocol error frames (e.g. a request-validation 400) do # NOT carry `sequence_number`, which ResponseErrorEvent marks required. # Validating them as-is raises a pydantic ValidationError that masks # the real API message ("... 1 validation error ... sequence_number # Field required ..."). Default the field so the genuine error # surfaces as a clean APIStatusError via _handle_error instead. merged = {"sequence_number": -1, **event.get("error", {}), **event} return ResponseErrorEvent.model_validate(merged) elif event_type == "response.created": return ResponseCreatedEvent.model_validate(event) elif event_type == "response.output_item.done": return ResponseOutputItemDoneEvent.model_validate(event) elif event_type == "response.output_text.delta": return ResponseTextDeltaEvent.model_validate(event) elif event_type == "response.completed": return ResponseCompletedEvent.model_validate(event) elif event_type == "response.failed": return ResponseFailedEvent.model_validate(event) return None def _process_event(self, event: ResponseStreamEvent | None) -> None: if event is None: return chunk = None if isinstance(event, ResponseErrorEvent): self._handle_error(event) if isinstance(event, ResponseCreatedEvent): self._handle_response_created(event) if isinstance(event, ResponseOutputItemDoneEvent): chunk = self._handle_output_items_done(event) if isinstance(event, ResponseTextDeltaEvent): chunk = self._handle_response_output_text_delta(event) if isinstance(event, ResponseCompletedEvent): chunk = self._handle_response_completed(event) if isinstance(event, ResponseFailedEvent): self._handle_response_failed(event) if chunk is not None: self._event_ch.send_nowait(chunk) def _handle_error(self, event: ResponseErrorEvent) -> None: error_code = -1 try: error_code = int(event.code) if event.code else -1 except ValueError: pass raise APIStatusError(event.message, status_code=error_code, retryable=False) def _handle_response_failed(self, event: ResponseFailedEvent) -> None: err = event.response.error raise APIStatusError( err.message if err else "response.failed", status_code=-1, retryable=False, ) def _handle_response_created(self, event: ResponseCreatedEvent) -> None: self._response_id = event.response.id def _handle_response_completed(self, event: ResponseCompletedEvent) -> llm.ChatChunk | None: for item in event.response.output: # Every item.type is a discriminator of openai's ResponseOutputItem union. # Of those, only these are produced/consumed by the agent itself; all other # members of the union are tools the Responses API runs server-side (e.g. # openai web_search, xAI web_search and x_search's custom_tool_call subcalls), # so anything not in this set is a provider-executed tool. if item.type not in ("message", "reasoning", "function_call", "function_call_output"): logger.info( "provider tool executed", extra={ "tool_type": item.type, "result": item.model_dump(exclude_none=True), }, ) self._response_completed = True self._llm._prev_chat_ctx = self._full_chat_ctx self._llm._prev_resp_id = self._response_id self._llm._pending_tool_calls = self._pending_tool_calls chunk = None if usage := event.response.usage: chunk = llm.ChatChunk( id=self._response_id, usage=llm.CompletionUsage( completion_tokens=usage.output_tokens, prompt_tokens=usage.input_tokens, prompt_cached_tokens=usage.input_tokens_details.cached_tokens if usage.input_tokens_details else 0, total_tokens=usage.total_tokens, service_tier=getattr(event.response, "service_tier", None), ), ) return chunk def _handle_output_items_done(self, event: ResponseOutputItemDoneEvent) -> llm.ChatChunk | None: chunk = None if event.item.type == "function_call": chunk = llm.ChatChunk( id=self._response_id, delta=llm.ChoiceDelta( role="assistant", content=None, tool_calls=[ llm.FunctionToolCall( arguments=event.item.arguments, name=event.item.name, call_id=event.item.call_id, ) ], ), ) self._pending_tool_calls.add(event.item.call_id) elif isinstance(event.item, ResponseOutputMessage) and event.item.phase is not None: # Models like gpt-5.3-codex label assistant messages as intermediate # `commentary` or the `final_answer` chunk = llm.ChatChunk( id=self._response_id, delta=llm.ChoiceDelta( role="assistant", content=None, extra={"openai": {"phase": event.item.phase}}, ), ) return chunk def _handle_response_output_text_delta( self, event: ResponseTextDeltaEvent ) -> llm.ChatChunk | None: return llm.ChatChunk( id=self._response_id, delta=llm.ChoiceDelta(content=event.delta, role="assistant"), )