From f928d19d1dbea8a04536ca3da05d78857abd0679 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 18 Jun 2026 18:16:34 +0300 Subject: [PATCH] feat(openai): route OpenAI Realtime (WebSocket) through LLM Gateway UiPathOpenAI / UiPathAsyncOpenAI now expose `client.realtime.connect()` exactly like the stock OpenAI SDK, opening a WebSocket to the gateway's passthrough realtime endpoint (.../vendor//model//realtime). The .realtime resource points websocket_base_url at the gateway, sets the S2S bearer token as api_key (sent as Authorization: Bearer on the upgrade), and injects the X-UiPath-* routing headers. Completions/embeddings are unaffected: their auth uses the httpx pipeline, and the realtime URL is built lazily on .realtime access. - openai extra now installs openai[realtime] (pulls in websockets) - langchain openai extra pulls core[openai] so realtime works from a langchain install (LangChain has no realtime chat-model abstraction) - bump core + langchain to 1.15.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + README.md | 29 +++ packages/uipath_langchain_client/CHANGELOG.md | 6 + .../uipath_langchain_client/pyproject.toml | 6 +- .../uipath_langchain_client/__version__.py | 2 +- pyproject.toml | 2 +- src/uipath/llm_client/__version__.py | 2 +- .../llm_client/clients/openai/client.py | 43 ++++ .../llm_client/clients/openai/realtime.py | 214 ++++++++++++++++++ .../core/clients/openai/test_realtime_unit.py | 190 ++++++++++++++++ 10 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 src/uipath/llm_client/clients/openai/realtime.py create mode 100644 tests/core/clients/openai/test_realtime_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9894d..c8a2055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.15.0] - 2026-06-18 + +### Added +- **OpenAI Realtime (WebSocket) routed through the LLM Gateway.** `UiPathOpenAI` and `UiPathAsyncOpenAI` now expose `client.realtime.connect()`, exactly like the stock OpenAI SDK, opening a WebSocket to the gateway's passthrough realtime endpoint (`.../vendor//model//realtime`). On connect the client points its `websocket_base_url` at the gateway, refreshes the S2S bearer token into `api_key` (sent as `Authorization: Bearer` on the WebSocket upgrade), and injects the `X-UiPath-*` routing headers. The realtime URL uses the `nativeopenai` vendor segment and is built lazily on `.realtime` access, so completions/embeddings construction and auth are unaffected. New helper `build_realtime_ws_base_url(settings, model_name=..., vendor_type=...)` in `uipath.llm_client.clients.openai.realtime`. +- The `openai` optional extra now installs `openai[realtime]`, pulling in the `websockets` dependency required for realtime connections. + ## [1.14.0] - 2026-06-15 ### Added diff --git a/README.md b/README.md index be3a18b..8f9148c 100644 --- a/README.md +++ b/README.md @@ -466,6 +466,35 @@ All native SDK wrappers are available in sync and async variants: | `UiPathAnthropicFoundry` / `UiPathAsyncAnthropicFoundry` | `anthropic.AnthropicFoundry` | Anthropic via Azure Foundry | | `UiPathGoogle` | `google.genai.Client` | Google Gemini models | +#### Realtime (WebSocket) + +`UiPathOpenAI` / `UiPathAsyncOpenAI` also expose the OpenAI Realtime API over a WebSocket, routed through the LLM Gateway. Use `client.realtime.connect()` exactly as with the stock OpenAI SDK — the bearer token and `X-UiPath-*` routing headers are applied on the WebSocket upgrade automatically. Requires the `openai` extra (it pulls in `websockets`). + +```python +import asyncio +from uipath.llm_client.clients.openai import UiPathAsyncOpenAI + +async def main(): + client = UiPathAsyncOpenAI(model_name="gpt-realtime") + async with client.realtime.connect() as conn: + await conn.session.update(session={"type": "realtime", "output_modalities": ["text"]}) + await conn.conversation.item.create( + item={ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Say hello!"}], + } + ) + await conn.response.create() + async for event in conn: + if event.type == "response.output_text.delta": + print(event.delta, end="", flush=True) + elif event.type == "response.done": + break + +asyncio.run(main()) +``` + ### Low-Level HTTP Client For completely custom HTTP requests, use the low-level HTTPX client directly: diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index e6f40ae..40dc0ce 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.15.0] - 2026-06-18 + +### Changed +- Bumped `uipath-llm-client` floor to `>=1.15.0` to pick up OpenAI Realtime (WebSocket) support on `UiPathOpenAI` / `UiPathAsyncOpenAI` (`client.realtime.connect()` routed through the LLM Gateway). The realtime clients live in the core package (`uipath.llm_client.clients.openai`); no LangChain-specific wrapper is added, since LangChain has no realtime chat-model abstraction — realtime is used by dropping down to the core client. +- The `openai` extra now also installs `uipath-llm-client[openai]` (which pulls `openai[realtime]` → `websockets`), so realtime works out of the box from a `uipath-langchain-client[openai]` install. + ## [1.14.0] - 2026-06-15 ### Added diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 3261248..1b10ebe 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -6,12 +6,16 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "langchain>=1.2.15,<2.0.0", - "uipath-llm-client>=1.14.0,<2.0.0", + "uipath-llm-client>=1.15.0,<2.0.0", ] [project.optional-dependencies] openai = [ "langchain-openai>=1.2.0,<2.0.0", + # Pulls the core OpenAI extra (openai[realtime] -> websockets) so realtime is + # usable via uipath.llm_client.clients.openai.UiPathAsyncOpenAI from a + # langchain install. LangChain itself has no realtime chat-model abstraction. + "uipath-llm-client[openai]>=1.15.0,<2.0.0", ] google = [ "langchain-google-genai>=4.2.2,<5.0.0", diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index 6e980c9..b33880d 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LangChain Client" __description__ = "A Python client for interacting with UiPath's LLM services via LangChain." -__version__ = "1.14.0" +__version__ = "1.15.0" diff --git a/pyproject.toml b/pyproject.toml index 021f3bf..746f672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ authors = [ [project.optional-dependencies] openai = [ - "openai>=2.30.0,<3.0.0", + "openai[realtime]>=2.30.0,<3.0.0", ] google = [ "google-genai>=1.73.1,<2.0.0", diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index fdf225c..520e628 100644 --- a/src/uipath/llm_client/__version__.py +++ b/src/uipath/llm_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LLM Client" __description__ = "A Python client for interacting with UiPath's LLM services." -__version__ = "1.14.0" +__version__ = "1.15.0" diff --git a/src/uipath/llm_client/clients/openai/client.py b/src/uipath/llm_client/clients/openai/client.py index 67d4c4a..4d1fc3a 100644 --- a/src/uipath/llm_client/clients/openai/client.py +++ b/src/uipath/llm_client/clients/openai/client.py @@ -1,6 +1,12 @@ import logging from collections.abc import Mapping, Sequence +from functools import cached_property +from uipath.llm_client.clients.openai.realtime import ( + DEFAULT_REALTIME_VENDOR, + _UiPathAsyncRealtime, + _UiPathRealtime, +) from uipath.llm_client.clients.openai.utils import OpenAIRequestHandler from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient, UiPathHttpxClient from uipath.llm_client.settings import UiPathBaseSettings, get_default_client_settings @@ -8,6 +14,7 @@ try: from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI + from openai.resources.realtime import AsyncRealtime, Realtime except ImportError as e: raise ImportError( "The 'openai' extra is required to use UiPathOpenAIClient. " @@ -74,6 +81,24 @@ def __init__( http_client=httpx_client, base_url=str(httpx_client.base_url).rstrip("/"), ) + self._uipath_client_settings = client_settings + self._uipath_realtime_model = model_name + + # Subtype override of the SDK's cached_property; returns a gateway-routed + # Realtime resource. pyright flags any cached_property override, so suppress. + @cached_property + def realtime(self) -> Realtime: # pyright: ignore[reportIncompatibleMethodOverride] + """OpenAI Realtime (WebSocket) resource routed through the LLM Gateway. + + Use ``with client.realtime.connect() as conn:`` — the connection targets + the gateway's passthrough realtime endpoint for this client's model. + """ + return _UiPathRealtime( + self, + settings=self._uipath_client_settings, + model=self._uipath_realtime_model, + vendor_type=DEFAULT_REALTIME_VENDOR, + ) class UiPathAsyncOpenAI(AsyncOpenAI): @@ -135,6 +160,24 @@ def __init__( http_client=httpx_client, base_url=str(httpx_client.base_url).rstrip("/"), ) + self._uipath_client_settings = client_settings + self._uipath_realtime_model = model_name + + # Subtype override of the SDK's cached_property; returns a gateway-routed + # AsyncRealtime resource. pyright flags any cached_property override, so suppress. + @cached_property + def realtime(self) -> AsyncRealtime: # pyright: ignore[reportIncompatibleMethodOverride] + """OpenAI Realtime (WebSocket) resource routed through the LLM Gateway. + + Use ``async with client.realtime.connect() as conn:`` — the connection + targets the gateway's passthrough realtime endpoint for this client's model. + """ + return _UiPathAsyncRealtime( + self, + settings=self._uipath_client_settings, + model=self._uipath_realtime_model, + vendor_type=DEFAULT_REALTIME_VENDOR, + ) class UiPathAzureOpenAI(AzureOpenAI): diff --git a/src/uipath/llm_client/clients/openai/realtime.py b/src/uipath/llm_client/clients/openai/realtime.py new file mode 100644 index 0000000..6c28892 --- /dev/null +++ b/src/uipath/llm_client/clients/openai/realtime.py @@ -0,0 +1,214 @@ +"""Realtime (WebSocket) support for the UiPath OpenAI clients. + +The OpenAI Realtime API speaks over a WebSocket rather than HTTP, so it does not +go through the httpx routing used for completions/embeddings. Instead, +``UiPathOpenAI`` / ``UiPathAsyncOpenAI`` expose ``client.realtime.connect()`` — +exactly like the stock OpenAI SDK — by swapping in the resource wrappers defined +here. On connect these wrappers: + +- point the client's ``websocket_base_url`` at the gateway passthrough realtime + path (``.../vendor//model/``); the SDK appends ``/realtime``, +- set the S2S bearer token as ``api_key`` (the SDK sends it as + ``Authorization: Bearer`` on the WebSocket upgrade), +- inject the ``X-UiPath-*`` routing headers on the upgrade request. + +Completions/embeddings are unaffected: their auth comes from the httpx auth +pipeline, and the realtime URL is only built when ``.realtime`` is accessed. + +Example: + >>> import asyncio + >>> from uipath.llm_client.clients.openai import UiPathAsyncOpenAI + >>> + >>> async def main(): + ... client = UiPathAsyncOpenAI(model_name="gpt-realtime") + ... async with client.realtime.connect() as conn: + ... await conn.session.update( + ... session={"type": "realtime", "output_modalities": ["text"]} + ... ) + ... await conn.conversation.item.create( + ... item={ + ... "type": "message", + ... "role": "user", + ... "content": [{"type": "input_text", "text": "Say hello!"}], + ... } + ... ) + ... await conn.response.create() + ... async for event in conn: + ... if event.type == "response.output_text.delta": + ... print(event.delta, end="") + ... elif event.type == "response.done": + ... break + >>> asyncio.run(main()) +""" + +import re + +from typing_extensions import override + +from uipath.llm_client.settings import UiPathAPIConfig, UiPathBaseSettings +from uipath.llm_client.settings.constants import RoutingMode +from uipath.llm_client.settings.llmgateway.auth import LLMGatewayS2SAuth + +try: + from openai import AsyncOpenAI, OpenAI + from openai._types import Headers, Omit, Query, omit + from openai.resources.realtime import AsyncRealtime, Realtime + from openai.resources.realtime.realtime import ( + AsyncRealtimeConnectionManager, + RealtimeConnectionManager, + ) + from openai.types.websocket_connection_options import WebSocketConnectionOptions +except ImportError as e: + raise ImportError( + "The 'openai' extra is required for realtime support. " + "Install it with: uv add uipath-llm-client[openai]" + ) from e + +# The gateway expects the native-OpenAI realtime vendor segment in the path. +DEFAULT_REALTIME_VENDOR = "nativeopenai" +# Passthrough api_type segment for the realtime endpoint (not a normalized ApiType). +REALTIME_API_TYPE = "realtime" + + +def _realtime_api_config(vendor_type: str) -> UiPathAPIConfig: + return UiPathAPIConfig( + api_type=REALTIME_API_TYPE, + routing_mode=RoutingMode.PASSTHROUGH, + vendor_type=vendor_type, + ) + + +def build_realtime_ws_base_url( + settings: UiPathBaseSettings, + *, + model_name: str, + vendor_type: str = DEFAULT_REALTIME_VENDOR, +) -> str: + """Build the ``websocket_base_url`` to hand to the OpenAI SDK. + + The SDK appends ``/realtime`` to ``websocket_base_url`` when connecting, so + this strips the trailing ``/realtime`` produced by ``build_base_url`` and + converts the scheme to ``wss``/``ws``. + """ + url = settings.build_base_url( + model_name=model_name, api_config=_realtime_api_config(vendor_type) + ) + suffix = f"/{REALTIME_API_TYPE}" + if url.endswith(suffix): + url = url[: -len(suffix)] + # Collapse accidental double slashes (e.g. a trailing slash in base_url), + # leaving the scheme's own "//" intact. + scheme, sep, rest = url.partition("://") + if sep: + url = f"{scheme}://{re.sub(r'/{2,}', '/', rest)}" + if url.startswith("https://"): + return "wss://" + url[len("https://") :] + if url.startswith("http://"): + return "ws://" + url[len("http://") :] + return url + + +def _prepare_connection( + client: "OpenAI | AsyncOpenAI", + settings: UiPathBaseSettings, + *, + model: str, + vendor_type: str, + extra_headers: Headers, +) -> Headers: + """Configure ``client`` for a gateway realtime connection to ``model``. + + Sets ``websocket_base_url`` and the S2S bearer token (read straight from the + gateway auth handler), and returns the ``X-UiPath-*`` routing headers merged + with ``extra_headers`` for the WebSocket upgrade. + """ + client.websocket_base_url = build_realtime_ws_base_url( + settings, model_name=model, vendor_type=vendor_type + ) + auth = settings.build_auth_pipeline() + if isinstance(auth, LLMGatewayS2SAuth) and auth.access_token: + client.api_key = auth.access_token + merged: dict[str, object] = { + **settings.build_auth_headers( + model_name=model, api_config=_realtime_api_config(vendor_type) + ) + } + if extra_headers: + merged.update(extra_headers) + return merged # type: ignore[return-value] + + +class _UiPathRealtime(Realtime): + """``Realtime`` resource that routes ``connect()`` through the gateway.""" + + def __init__( + self, client: OpenAI, *, settings: UiPathBaseSettings, model: str, vendor_type: str + ) -> None: + super().__init__(client) + self._uipath_settings = settings + self._uipath_model = model + self._uipath_vendor = vendor_type + + @override + def connect( + self, + *, + call_id: str | Omit = omit, + model: str | Omit = omit, + extra_query: Query = {}, + extra_headers: Headers = {}, + websocket_connection_options: WebSocketConnectionOptions = {}, + ) -> RealtimeConnectionManager: + resolved_model: str = self._uipath_model if model is omit else model # type: ignore[assignment] + merged_headers = _prepare_connection( + self._client, + self._uipath_settings, + model=resolved_model, + vendor_type=self._uipath_vendor, + extra_headers=extra_headers, + ) + return super().connect( + call_id=call_id, + model=resolved_model, + extra_query=extra_query, + extra_headers=merged_headers, + websocket_connection_options=websocket_connection_options, + ) + + +class _UiPathAsyncRealtime(AsyncRealtime): + """``AsyncRealtime`` resource that routes ``connect()`` through the gateway.""" + + def __init__( + self, client: AsyncOpenAI, *, settings: UiPathBaseSettings, model: str, vendor_type: str + ) -> None: + super().__init__(client) + self._uipath_settings = settings + self._uipath_model = model + self._uipath_vendor = vendor_type + + @override + def connect( + self, + *, + call_id: str | Omit = omit, + model: str | Omit = omit, + extra_query: Query = {}, + extra_headers: Headers = {}, + websocket_connection_options: WebSocketConnectionOptions = {}, + ) -> AsyncRealtimeConnectionManager: + resolved_model: str = self._uipath_model if model is omit else model # type: ignore[assignment] + merged_headers = _prepare_connection( + self._client, + self._uipath_settings, + model=resolved_model, + vendor_type=self._uipath_vendor, + extra_headers=extra_headers, + ) + return super().connect( + call_id=call_id, + model=resolved_model, + extra_query=extra_query, + extra_headers=merged_headers, + websocket_connection_options=websocket_connection_options, + ) diff --git a/tests/core/clients/openai/test_realtime_unit.py b/tests/core/clients/openai/test_realtime_unit.py new file mode 100644 index 0000000..e95eaaf --- /dev/null +++ b/tests/core/clients/openai/test_realtime_unit.py @@ -0,0 +1,190 @@ +# pyright: reportPrivateUsage=false, reportAttributeAccessIssue=false +"""Unit tests for realtime support on the UiPath OpenAI clients (no network). + +Covers the URL helper and the ``client.realtime`` resource wiring: lazy URL +construction, websocket_base_url + api_key setup on connect, routing header +injection, default-model resolution, and token refresh. +""" + +from unittest.mock import MagicMock, patch + +import httpx +from openai.resources.realtime import AsyncRealtime, Realtime + +from uipath.llm_client.clients.openai import UiPathAsyncOpenAI, UiPathOpenAI +from uipath.llm_client.clients.openai.realtime import ( + _UiPathAsyncRealtime, + _UiPathRealtime, + build_realtime_ws_base_url, +) +from uipath.llm_client.settings import UiPathBaseSettings +from uipath.llm_client.settings.llmgateway.auth import LLMGatewayS2SAuth + +_CLIENT_MODULE = "uipath.llm_client.clients.openai.client" + +_REALTIME_PATH = ( + "https://gw.uipath.com/org/tenant/llmgateway_/api/raw" + "/vendor/nativeopenai/model/gpt-realtime/realtime" +) +_WS_BASE = ( + "wss://gw.uipath.com/org/tenant/llmgateway_/api/raw/vendor/nativeopenai/model/gpt-realtime" +) + + +def _fake_s2s_auth(token: str | None) -> MagicMock: + # spec=LLMGatewayS2SAuth so isinstance() in _prepare_connection passes. + auth = MagicMock(spec=LLMGatewayS2SAuth) + auth.access_token = token + return auth + + +def _fake_settings( + *, + url: str = _REALTIME_PATH, + headers: dict[str, str] | None = None, + token: str | None = "tok-abc", +) -> MagicMock: + settings = MagicMock(spec=UiPathBaseSettings) + settings.build_base_url.return_value = url + settings.build_auth_headers.return_value = headers or {"X-UiPath-Internal-AccountId": "org"} + settings.build_auth_pipeline.return_value = _fake_s2s_auth(token) + return settings + + +def _mock_httpx_sync_client() -> MagicMock: + client = MagicMock(spec=httpx.Client) + client.base_url = "https://gw.uipath.com/llm/v1" + client.headers = httpx.Headers() + client._transport = MagicMock() + client._base_url = httpx.URL("https://gw.uipath.com/llm/v1") + return client + + +def _mock_httpx_async_client() -> MagicMock: + client = MagicMock(spec=httpx.AsyncClient) + client.base_url = "https://gw.uipath.com/llm/v1" + client.headers = httpx.Headers() + client._transport = MagicMock() + client._base_url = httpx.URL("https://gw.uipath.com/llm/v1") + return client + + +# ============================================================================ +# build_realtime_ws_base_url +# ============================================================================ + + +class TestBuildRealtimeWsBaseUrl: + def test_strips_realtime_suffix_and_converts_scheme(self) -> None: + url = build_realtime_ws_base_url( + _fake_settings(), model_name="gpt-realtime", vendor_type="nativeopenai" + ) + assert url == _WS_BASE + + def test_collapses_double_slashes_from_trailing_base_url(self) -> None: + settings = _fake_settings( + url="https://gw.uipath.com//org/tenant/llmgateway_/api/raw" + "/vendor/nativeopenai/model/gpt-realtime/realtime" + ) + url = build_realtime_ws_base_url(settings, model_name="gpt-realtime") + assert url == _WS_BASE + assert "//org" not in url + assert url.startswith("wss://") + + def test_http_maps_to_ws(self) -> None: + settings = _fake_settings( + url="http://localhost:7091/o/t/llmgateway_/api/raw" + "/vendor/nativeopenai/model/gpt-realtime/realtime" + ) + url = build_realtime_ws_base_url(settings, model_name="gpt-realtime") + assert url.startswith("ws://") + assert url.endswith("/model/gpt-realtime") + + def test_passes_realtime_api_config(self) -> None: + settings = _fake_settings() + build_realtime_ws_base_url(settings, model_name="gpt-realtime", vendor_type="nativeopenai") + cfg = settings.build_base_url.call_args.kwargs["api_config"] + assert cfg.api_type == "realtime" + assert str(cfg.routing_mode) == "passthrough" + assert cfg.vendor_type == "nativeopenai" + + +# ============================================================================ +# Sync client .realtime wiring +# ============================================================================ + + +@patch(f"{_CLIENT_MODULE}.UiPathHttpxClient", return_value=_mock_httpx_sync_client()) +class TestSyncRealtimeProperty: + def test_realtime_returns_wrapper(self, _mock_build: MagicMock) -> None: + client = UiPathOpenAI(model_name="gpt-realtime", client_settings=_fake_settings()) + assert isinstance(client.realtime, _UiPathRealtime) + + def test_construction_does_not_build_realtime_url(self, _mock_build: MagicMock) -> None: + settings = _fake_settings() + UiPathOpenAI(model_name="gpt-realtime", client_settings=settings) + settings.build_base_url.assert_not_called() # built lazily on connect + + def test_connect_sets_ws_url_token_and_headers(self, _mock_build: MagicMock) -> None: + client = UiPathOpenAI( + model_name="gpt-realtime", + client_settings=_fake_settings( + headers={"X-UiPath-Internal-AccountId": "org"}, token="tok-1" + ), + ) + with patch.object(Realtime, "connect", return_value="MANAGER") as mock_connect: + result = client.realtime.connect(extra_headers={"X-Extra": "e"}) + + assert result == "MANAGER" + assert client.websocket_base_url == _WS_BASE + assert client.api_key == "tok-1" + kwargs = mock_connect.call_args.kwargs + assert kwargs["model"] == "gpt-realtime" + assert kwargs["extra_headers"]["X-UiPath-Internal-AccountId"] == "org" + assert kwargs["extra_headers"]["X-Extra"] == "e" + + def test_explicit_model_overrides_default(self, _mock_build: MagicMock) -> None: + client = UiPathOpenAI(model_name="gpt-realtime", client_settings=_fake_settings()) + with patch.object(Realtime, "connect", return_value="M") as mock_connect: + client.realtime.connect(model="gpt-realtime-2") + assert mock_connect.call_args.kwargs["model"] == "gpt-realtime-2" + + def test_connect_refreshes_token(self, _mock_build: MagicMock) -> None: + settings = _fake_settings(token="old") + client = UiPathOpenAI(model_name="gpt-realtime", client_settings=settings) + with patch.object(Realtime, "connect", return_value="M"): + client.realtime.connect() + assert client.api_key == "old" + settings.build_auth_pipeline.return_value = _fake_s2s_auth("refreshed") + client.realtime.connect() + assert client.api_key == "refreshed" + + +# ============================================================================ +# Async client .realtime wiring +# ============================================================================ + + +@patch(f"{_CLIENT_MODULE}.UiPathHttpxAsyncClient", return_value=_mock_httpx_async_client()) +class TestAsyncRealtimeProperty: + def test_realtime_returns_wrapper(self, _mock_build: MagicMock) -> None: + client = UiPathAsyncOpenAI(model_name="gpt-realtime", client_settings=_fake_settings()) + assert isinstance(client.realtime, _UiPathAsyncRealtime) + + def test_connect_sets_ws_url_token_and_headers(self, _mock_build: MagicMock) -> None: + client = UiPathAsyncOpenAI( + model_name="gpt-realtime", + client_settings=_fake_settings( + headers={"X-UiPath-Internal-AccountId": "org"}, token="tok-1" + ), + ) + with patch.object(AsyncRealtime, "connect", return_value="MANAGER") as mock_connect: + result = client.realtime.connect(extra_headers={"X-Extra": "e"}) + + assert result == "MANAGER" + assert client.websocket_base_url == _WS_BASE + assert client.api_key == "tok-1" + kwargs = mock_connect.call_args.kwargs + assert kwargs["model"] == "gpt-realtime" + assert kwargs["extra_headers"]["X-UiPath-Internal-AccountId"] == "org" + assert kwargs["extra_headers"]["X-Extra"] == "e"