Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath_langchain_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from uipath_langchain_client.settings import (
API_FLAVOR_TO_VENDOR_TYPE,
BYOM_TO_ROUTING_FLAVOR,
ApiFlavor,
RoutingMode,
UiPathBaseSettings,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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}'. "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -39,6 +41,8 @@
"ApiType",
"RoutingMode",
"ApiFlavor",
"ByomApiFlavor",
"VendorType",
"API_FLAVOR_TO_VENDOR_TYPE",
"BYOM_TO_ROUTING_FLAVOR",
]
2 changes: 1 addition & 1 deletion src/uipath/llm_client/__version__.py
Original file line number Diff line number Diff line change
@@ -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"
22 changes: 21 additions & 1 deletion src/uipath/llm_client/clients/litellm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"):
Expand Down
8 changes: 6 additions & 2 deletions src/uipath/llm_client/clients/openai/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"):
Expand Down
9 changes: 8 additions & 1 deletion src/uipath/llm_client/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -96,4 +102,5 @@ def get_default_client_settings(
"RoutingMode",
"VendorType",
"ApiFlavor",
"ByomApiFlavor",
]
Loading