diff --git a/CHANGELOG.md b/CHANGELOG.md index c21c211..1fc2405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.8.3] - 2026-04-16 + +### Added +- BYOM API flavor constants for discovery endpoint: `OpenAiChatCompletions`, `OpenAiResponses`, `OpenAiEmbeddings`, `GeminiGenerateContent`, `GeminiEmbeddings`, `AwsBedrockInvoke`, `AwsBedrockConverse` +- `BYOM_TO_ROUTING_FLAVOR` mapping to resolve BYOM discovery flavors to routing-level API flavors +- Extended `API_FLAVOR_TO_VENDOR_TYPE` with BYOM flavor entries for automatic vendor resolution +- LiteLLM client now resolves BYOM discovery flavors to correct routing flavors and litellm providers + ## [1.8.2] - 2026-04-13 ### Fixed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 069d75b..9a34ed2 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.8.3] - 2026-04-16 + +### Added +- Factory functions (`get_chat_model`, `get_embedding_model`) now automatically resolve BYOM discovery API flavors to the correct client and routing flavor + ## [1.8.2] - 2026-04-13 ### Changed diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 13083ac..942c8e5 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "langchain>=1.2.15", - "uipath-llm-client>=1.8.2", + "uipath-llm-client>=1.8.3", ] [project.optional-dependencies] 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 61de8d7..514ff65 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.8.2" +__version__ = "1.8.3" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/azure/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/azure/chat_models.py index 7af831f..9e4bf26 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/azure/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/azure/chat_models.py @@ -6,6 +6,7 @@ from uipath_langchain_client.base_client import UiPathBaseChatModel from uipath_langchain_client.clients.openai.utils import fix_url_and_api_flavor_header from uipath_langchain_client.settings import ( + ApiFlavor, ApiType, RoutingMode, UiPathAPIConfig, @@ -31,6 +32,7 @@ class UiPathAzureAIChatCompletionsModel(UiPathBaseChatModel, AzureAIOpenAIApiCha vendor_type=VendorType.AZURE, freeze_base_url=False, ) + api_flavor: ApiFlavor | str | None = None # Override fields to avoid env var lookup / validation errors at instantiation endpoint: str | None = Field(default="PLACEHOLDER") @@ -40,13 +42,16 @@ class UiPathAzureAIChatCompletionsModel(UiPathBaseChatModel, AzureAIOpenAIApiCha @model_validator(mode="after") def setup_uipath_client(self) -> Self: + if self.api_flavor is not None: + self.api_config.api_flavor = self.api_flavor base_url = str(self.uipath_sync_client.base_url).rstrip("/") + locked_flavor = str(self.api_config.api_flavor) if self.api_config.api_flavor else None def on_request(request: Request) -> None: - fix_url_and_api_flavor_header(base_url, request) + fix_url_and_api_flavor_header(base_url, request, api_flavor=locked_flavor) async def on_request_async(request: Request) -> None: - fix_url_and_api_flavor_header(base_url, request) + fix_url_and_api_flavor_header(base_url, request, api_flavor=locked_flavor) self.uipath_sync_client.event_hooks["request"].append(on_request) self.uipath_async_client.event_hooks["request"].append(on_request_async) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py index 4278c1f..af0521b 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py @@ -7,6 +7,7 @@ from uipath_langchain_client.base_client import UiPathBaseChatModel from uipath_langchain_client.clients.openai.utils import fix_url_and_api_flavor_header from uipath_langchain_client.settings import ( + ApiFlavor, ApiType, RoutingMode, UiPathAPIConfig, @@ -31,6 +32,7 @@ class UiPathChatOpenAI(UiPathBaseChatModel, ChatOpenAI): # type: ignore[overrid api_version="2025-03-01-preview", freeze_base_url=False, ) + api_flavor: ApiFlavor | str | None = None # Override fields to avoid errors when instantiating the class openai_api_key: SecretStr | None | Callable[[], str] | Callable[[], Awaitable[str]] = Field( @@ -39,13 +41,24 @@ class UiPathChatOpenAI(UiPathBaseChatModel, ChatOpenAI): # type: ignore[overrid @model_validator(mode="after") def setup_uipath_client(self) -> Self: + if self.api_flavor is not None: + self.api_config.api_flavor = self.api_flavor + # Lock LangChain's use_responses_api to match the discovered flavor. + # Without this, features like reasoning={} or certain model names + # silently switch LangChain to the Responses API, which would fail + # if the model only supports chat-completions (or vice versa). + if self.api_flavor == ApiFlavor.CHAT_COMPLETIONS: + self.use_responses_api = False + elif self.api_flavor == ApiFlavor.RESPONSES: + self.use_responses_api = True base_url = str(self.uipath_sync_client.base_url).rstrip("/") + locked_flavor = str(self.api_config.api_flavor) if self.api_config.api_flavor else None def on_request(request: Request) -> None: - fix_url_and_api_flavor_header(base_url, request) + fix_url_and_api_flavor_header(base_url, request, api_flavor=locked_flavor) async def on_request_async(request: Request) -> None: - fix_url_and_api_flavor_header(base_url, request) + fix_url_and_api_flavor_header(base_url, request, api_flavor=locked_flavor) self.uipath_sync_client.event_hooks["request"].append(on_request) self.uipath_async_client.event_hooks["request"].append(on_request_async) @@ -75,6 +88,7 @@ class UiPathAzureChatOpenAI(UiPathBaseChatModel, AzureChatOpenAI): # type: igno api_version="2025-03-01-preview", freeze_base_url=False, ) + api_flavor: ApiFlavor | str | None = None # Override fields to avoid errors when instantiating the class azure_endpoint: str | None = Field(default="PLACEHOLDER") @@ -83,13 +97,20 @@ class UiPathAzureChatOpenAI(UiPathBaseChatModel, AzureChatOpenAI): # type: igno @model_validator(mode="after") def setup_uipath_client(self) -> Self: + if self.api_flavor is not None: + self.api_config.api_flavor = self.api_flavor + if self.api_flavor == ApiFlavor.CHAT_COMPLETIONS: + self.use_responses_api = False + elif self.api_flavor == ApiFlavor.RESPONSES: + self.use_responses_api = True base_url = str(self.uipath_sync_client.base_url).rstrip("/") + locked_flavor = str(self.api_config.api_flavor) if self.api_config.api_flavor else None def on_request(request: Request) -> None: - fix_url_and_api_flavor_header(base_url, request) + fix_url_and_api_flavor_header(base_url, request, api_flavor=locked_flavor) async def on_request_async(request: Request) -> None: - fix_url_and_api_flavor_header(base_url, request) + fix_url_and_api_flavor_header(base_url, request, api_flavor=locked_flavor) self.uipath_sync_client.event_hooks["request"].append(on_request) self.uipath_async_client.event_hooks["request"].append(on_request_async) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/utils.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/utils.py index 03ecbbb..ba2e42c 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/utils.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/utils.py @@ -5,22 +5,28 @@ from uipath_langchain_client.settings import ApiFlavor -def fix_url_and_api_flavor_header(base_url: str, request: Request) -> None: - """Detect API flavor from URL suffix and rewrite the URL to the base gateway URL. +def fix_url_and_api_flavor_header( + base_url: str, request: Request, *, api_flavor: str | None = None +) -> None: + """Set the API flavor header and rewrite the URL to the base gateway URL. - Inspects the outgoing request URL to determine whether it targets the - OpenAI *responses* or *chat completions* endpoint and sets the - ``X-UiPath-LlmGateway-ApiFlavor`` header accordingly. The request URL - is then collapsed back to *base_url* so that the gateway receives a - clean path. + When *api_flavor* is provided (e.g. from the discovery endpoint), it is + used directly — the model only supports that specific flavor. Otherwise + the flavor is inferred from the outgoing URL suffix (``/responses`` vs + ``/chat/completions``). Args: base_url: The UiPath gateway base URL to rewrite the request to. request: The outgoing httpx request (mutated in place). + api_flavor: Locked API flavor from discovery. When set, overrides + dynamic detection from the URL path. """ - url_suffix = str(request.url).split(base_url)[-1] - if "responses" in url_suffix: - request.headers["X-UiPath-LlmGateway-ApiFlavor"] = ApiFlavor.RESPONSES.value + if api_flavor is not None: + request.headers["X-UiPath-LlmGateway-ApiFlavor"] = api_flavor else: - request.headers["X-UiPath-LlmGateway-ApiFlavor"] = ApiFlavor.CHAT_COMPLETIONS.value + url_suffix = str(request.url).split(base_url)[-1] + if "responses" in url_suffix: + request.headers["X-UiPath-LlmGateway-ApiFlavor"] = ApiFlavor.RESPONSES.value + else: + request.headers["X-UiPath-LlmGateway-ApiFlavor"] = ApiFlavor.CHAT_COMPLETIONS.value request.url = URL(base_url) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py index 163df11..f4b8c05 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py @@ -28,6 +28,7 @@ ) from uipath_langchain_client.settings import ( API_FLAVOR_TO_VENDOR_TYPE, + BYOM_TO_ROUTING_FLAVOR, ApiFlavor, RoutingMode, UiPathBaseSettings, @@ -160,11 +161,16 @@ def get_chat_model( raise ValueError("No vendor type or api flavor found in model info") discovered_vendor_type = discovered_vendor_type.lower() + # Discovered api_flavor takes precedence over user-supplied api_flavor + if discovered_api_flavor is not None: + routing_flavor = BYOM_TO_ROUTING_FLAVOR.get(discovered_api_flavor) + if routing_flavor is not None: + api_flavor = routing_flavor + else: + api_flavor = discovered_api_flavor + match discovered_vendor_type: case VendorType.OPENAI: - if api_flavor == ApiFlavor.RESPONSES: - model_kwargs["use_responses_api"] = True - if is_uipath_owned: from uipath_langchain_client.clients.openai.chat_models import ( UiPathAzureChatOpenAI, @@ -173,6 +179,7 @@ def get_chat_model( return UiPathAzureChatOpenAI( model=model_name, settings=client_settings, + api_flavor=api_flavor, byo_connection_id=byo_connection_id, **model_kwargs, ) @@ -184,6 +191,7 @@ def get_chat_model( return UiPathChatOpenAI( model=model_name, settings=client_settings, + api_flavor=api_flavor, byo_connection_id=byo_connection_id, **model_kwargs, ) @@ -323,10 +331,9 @@ def get_embedding_model( ) discovered_vendor_type = model_info.get("vendor") - if discovered_vendor_type is None: - discovered_api_flavor = model_info.get("apiFlavor") - if discovered_api_flavor is not None: - discovered_vendor_type = API_FLAVOR_TO_VENDOR_TYPE.get(discovered_api_flavor) + discovered_api_flavor = model_info.get("apiFlavor") + if discovered_vendor_type is None and discovered_api_flavor is not None: + discovered_vendor_type = API_FLAVOR_TO_VENDOR_TYPE.get(discovered_api_flavor) if discovered_vendor_type is None: raise ValueError( f"No vendor type found in model info for embedding model '{model_name}'. " diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py b/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py index 32c9a4b..83d739a 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py @@ -24,8 +24,10 @@ ) from uipath.llm_client.settings.constants import ( API_FLAVOR_TO_VENDOR_TYPE, + BYOM_TO_ROUTING_FLAVOR, ApiFlavor, ApiType, + ByomApiFlavor, RoutingMode, VendorType, ) @@ -39,6 +41,8 @@ "ApiType", "RoutingMode", "ApiFlavor", + "ByomApiFlavor", "VendorType", "API_FLAVOR_TO_VENDOR_TYPE", + "BYOM_TO_ROUTING_FLAVOR", ] diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 19a0cf5..ea22869 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.8.2" +__version__ = "1.8.3" diff --git a/src/uipath/llm_client/clients/litellm/client.py b/src/uipath/llm_client/clients/litellm/client.py index 6f5fdd8..93d55b5 100644 --- a/src/uipath/llm_client/clients/litellm/client.py +++ b/src/uipath/llm_client/clients/litellm/client.py @@ -27,8 +27,10 @@ from uipath.llm_client.settings.base import UiPathAPIConfig from uipath.llm_client.settings.constants import ( API_FLAVOR_TO_VENDOR_TYPE, + BYOM_TO_ROUTING_FLAVOR, ApiFlavor, ApiType, + ByomApiFlavor, RoutingMode, VendorType, ) @@ -87,6 +89,14 @@ "converse": "bedrock", "invoke": "bedrock", "anthropic-claude": "vertex_ai", + # BYOM discovery flavors + ByomApiFlavor.OPENAI_CHAT_COMPLETIONS: "openai", + ByomApiFlavor.OPENAI_RESPONSES: "openai", + ByomApiFlavor.OPENAI_EMBEDDINGS: "openai", + ByomApiFlavor.GEMINI_GENERATE_CONTENT: "gemini", + ByomApiFlavor.GEMINI_EMBEDDINGS: "gemini", + ByomApiFlavor.AWS_BEDROCK_INVOKE: "bedrock", + ByomApiFlavor.AWS_BEDROCK_CONVERSE: "bedrock", } _ANTHROPIC_FAMILY = "anthropicclaude" @@ -210,7 +220,17 @@ def _discover_and_build_api_config( raise ValueError(f"Cannot determine vendor for model '{self._model_name}'") resolved_vendor = str(vendor_type or discovered_vendor).lower() - resolved_flavor = str(api_flavor) if api_flavor is not None else discovered_flavor + + # Discovered BYOM api_flavor takes precedence over user-supplied api_flavor + routing_flavor = ( + BYOM_TO_ROUTING_FLAVOR.get(discovered_flavor) if discovered_flavor is not None else None + ) + if routing_flavor is not None: + resolved_flavor: str | None = routing_flavor + elif api_flavor is not None: + resolved_flavor = str(api_flavor) + else: + resolved_flavor = discovered_flavor # OpenAI defaults to chat-completions when no flavor is discovered if resolved_flavor is None and resolved_vendor in ("openai", "azure"): diff --git a/src/uipath/llm_client/clients/openai/utils.py b/src/uipath/llm_client/clients/openai/utils.py index 5674b6f..198d293 100644 --- a/src/uipath/llm_client/clients/openai/utils.py +++ b/src/uipath/llm_client/clients/openai/utils.py @@ -11,10 +11,12 @@ def __init__( model_name: str, client_settings: UiPathBaseSettings, byo_connection_id: str | None = None, + api_flavor: ApiFlavor | str | None = None, ): self.model_name = model_name self.client_settings = client_settings self.byo_connection_id = byo_connection_id + self.locked_api_flavor = str(api_flavor) if api_flavor else None self.base_api_config = UiPathAPIConfig( routing_mode=RoutingMode.PASSTHROUGH, vendor_type=VendorType.OPENAI, @@ -37,13 +39,15 @@ def _apply_routing(self, request: Request, api_config: UiPathAPIConfig) -> None: def fix_url_and_headers(self, request: Request): if request.url.path.endswith("/completions"): + flavor = self.locked_api_flavor or ApiFlavor.CHAT_COMPLETIONS api_config = self.base_api_config.model_copy( - update={"api_flavor": ApiFlavor.CHAT_COMPLETIONS, "api_type": ApiType.COMPLETIONS} + update={"api_flavor": flavor, "api_type": ApiType.COMPLETIONS} ) self._apply_routing(request, api_config) elif request.url.path.endswith("/responses"): + flavor = self.locked_api_flavor or ApiFlavor.RESPONSES api_config = self.base_api_config.model_copy( - update={"api_flavor": ApiFlavor.RESPONSES, "api_type": ApiType.COMPLETIONS} + update={"api_flavor": flavor, "api_type": ApiType.COMPLETIONS} ) self._apply_routing(request, api_config) elif request.url.path.endswith("/embeddings"): diff --git a/src/uipath/llm_client/settings/__init__.py b/src/uipath/llm_client/settings/__init__.py index d9204f6..13660dd 100644 --- a/src/uipath/llm_client/settings/__init__.py +++ b/src/uipath/llm_client/settings/__init__.py @@ -30,7 +30,13 @@ from typing import Literal from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings -from uipath.llm_client.settings.constants import ApiFlavor, ApiType, RoutingMode, VendorType +from uipath.llm_client.settings.constants import ( + ApiFlavor, + ApiType, + ByomApiFlavor, + RoutingMode, + VendorType, +) from uipath.llm_client.settings.llmgateway import LLMGatewaySettings from uipath.llm_client.settings.platform import PlatformSettings @@ -96,4 +102,5 @@ def get_default_client_settings( "RoutingMode", "VendorType", "ApiFlavor", + "ByomApiFlavor", ] diff --git a/src/uipath/llm_client/settings/constants.py b/src/uipath/llm_client/settings/constants.py index f8acf52..5d6e2c6 100644 --- a/src/uipath/llm_client/settings/constants.py +++ b/src/uipath/llm_client/settings/constants.py @@ -28,11 +28,39 @@ class ApiFlavor(StrEnum): ANTHROPIC_CLAUDE = "anthropic-claude" -API_FLAVOR_TO_VENDOR_TYPE: dict[ApiFlavor, VendorType] = { +class ByomApiFlavor(StrEnum): + """API flavors returned by the discovery endpoint for BYOM models.""" + + OPENAI_CHAT_COMPLETIONS = "OpenAiChatCompletions" + OPENAI_RESPONSES = "OpenAiResponses" + OPENAI_EMBEDDINGS = "OpenAiEmbeddings" + GEMINI_GENERATE_CONTENT = "GeminiGenerateContent" + GEMINI_EMBEDDINGS = "GeminiEmbeddings" + AWS_BEDROCK_INVOKE = "AwsBedrockInvoke" + AWS_BEDROCK_CONVERSE = "AwsBedrockConverse" + + +API_FLAVOR_TO_VENDOR_TYPE: dict[str, VendorType] = { ApiFlavor.CHAT_COMPLETIONS: VendorType.OPENAI, ApiFlavor.RESPONSES: VendorType.OPENAI, ApiFlavor.GENERATE_CONTENT: VendorType.VERTEXAI, ApiFlavor.ANTHROPIC_CLAUDE: VendorType.VERTEXAI, ApiFlavor.CONVERSE: VendorType.AWSBEDROCK, ApiFlavor.INVOKE: VendorType.AWSBEDROCK, + ByomApiFlavor.OPENAI_CHAT_COMPLETIONS: VendorType.OPENAI, + ByomApiFlavor.OPENAI_RESPONSES: VendorType.OPENAI, + ByomApiFlavor.OPENAI_EMBEDDINGS: VendorType.OPENAI, + ByomApiFlavor.GEMINI_GENERATE_CONTENT: VendorType.VERTEXAI, + ByomApiFlavor.GEMINI_EMBEDDINGS: VendorType.VERTEXAI, + ByomApiFlavor.AWS_BEDROCK_INVOKE: VendorType.AWSBEDROCK, + ByomApiFlavor.AWS_BEDROCK_CONVERSE: VendorType.AWSBEDROCK, +} + + +BYOM_TO_ROUTING_FLAVOR: dict[str, ApiFlavor] = { + ByomApiFlavor.OPENAI_CHAT_COMPLETIONS: ApiFlavor.CHAT_COMPLETIONS, + ByomApiFlavor.OPENAI_RESPONSES: ApiFlavor.RESPONSES, + ByomApiFlavor.GEMINI_GENERATE_CONTENT: ApiFlavor.GENERATE_CONTENT, + ByomApiFlavor.AWS_BEDROCK_INVOKE: ApiFlavor.INVOKE, + ByomApiFlavor.AWS_BEDROCK_CONVERSE: ApiFlavor.CONVERSE, }