Skip to content

Commit abaabc2

Browse files
cosminachoclaude
andcommitted
Feat: default OpenAI chat to Responses API where possible
Prefer the OpenAI Responses API (`ApiFlavor.RESPONSES`) when discovery signals both flavors are available, and when the langchain factory has no other flavor to route with: - `UiPathBaseSettings.get_model_info()`: when multiple OpenAI entries remain after existing filtering, prefer the `responses` / `OpenAiResponses` flavor over `chat-completions`. - `get_chat_model()` (langchain): inside the OpenAI match arm, default `api_flavor=RESPONSES` when discovery doesn't specify one and the caller didn't either. The LiteLLM client keeps `chat-completions` as its fallback for the single-entry / `apiFlavor=null` case. It serves both completion and embedding paths from the same instance, and the `responses/` model prefix in `_resolve_litellm_model` would break embedding calls if Responses were the default on a UiPath-owned OpenAI embedding model that reports `apiFlavor=null` at discovery. The discovery-level tie-break in `get_model_info` still benefits that client whenever the backend advertises both flavors explicitly. Bumps both packages to 1.9.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f5a09a9 commit abaabc2

10 files changed

Lines changed: 198 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to `uipath_llm_client` (core package) will be documented in this file.
44

5+
## [1.9.3] - 2026-04-20
6+
7+
### Changed
8+
- `UiPathBaseSettings.get_model_info()` now prefers the Responses API when discovery returns multiple OpenAI entries for the same model (both `chat-completions` and `responses` flavors present). The LiteLLM client keeps its `chat-completions` fallback for the single-entry / `apiFlavor=null` case because the same client serves embedding requests.
9+
510
## [1.9.2] - 2026-04-17
611

712
### Changed

packages/uipath_langchain_client/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to `uipath_langchain_client` will be documented in this file.
44

5+
## [1.9.3] - 2026-04-20
6+
7+
### Changed
8+
- `get_chat_model()` now defaults to the OpenAI Responses API (`ApiFlavor.RESPONSES`) when discovery does not specify a flavor for an OpenAI chat model. Explicit `api_flavor=` on the call and BYOM-discovered flavors still take precedence.
9+
- Minimum `uipath-llm-client` bumped to 1.9.3 for the `get_model_info()` Responses-preference tie-break.
10+
511
## [1.9.2] - 2026-04-17
612

713
### Changed

packages/uipath_langchain_client/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
88
"langchain>=1.2.15",
9-
"uipath-llm-client>=1.9.2",
9+
"uipath-llm-client>=1.9.3",
1010
]
1111

1212
[project.optional-dependencies]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__title__ = "UiPath LangChain Client"
22
__description__ = "A Python client for interacting with UiPath's LLM services via LangChain."
3-
__version__ = "1.9.2"
3+
__version__ = "1.9.3"

packages/uipath_langchain_client/src/uipath_langchain_client/factory.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ def get_chat_model(
124124

125125
match discovered_vendor_type:
126126
case VendorType.OPENAI:
127+
# OpenAI chat defaults to the Responses API when no flavor is specified.
128+
if api_flavor is None:
129+
api_flavor = ApiFlavor.RESPONSES
130+
127131
if model_family == ModelFamily.OPENAI:
128132
from uipath_langchain_client.clients.openai.chat_models import (
129133
UiPathAzureChatOpenAI,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__title__ = "UiPath LLM Client"
22
__description__ = "A Python client for interacting with UiPath's LLM services."
3-
__version__ = "1.9.2"
3+
__version__ = "1.9.3"

src/uipath/llm_client/clients/litellm/client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,11 @@ def _discover_and_build_api_config(
213213
else:
214214
resolved_flavor = discovered_flavor
215215

216-
# OpenAI defaults to chat-completions when no flavor is discovered
216+
# OpenAI defaults to chat-completions when no flavor is discovered.
217+
# RESPONSES is not a safe default here: this client serves both
218+
# completions and embeddings, and the ``responses/`` model prefix in
219+
# ``_resolve_litellm_model`` would break embedding calls on OpenAI
220+
# embedding models that discover with ``apiFlavor=null``.
217221
if resolved_flavor is None and resolved_vendor in ("openai", "azure"):
218222
resolved_flavor = ApiFlavor.CHAT_COMPLETIONS
219223

src/uipath/llm_client/settings/base.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
from pydantic import BaseModel, model_validator
1414
from pydantic_settings import BaseSettings, SettingsConfigDict
1515

16-
from uipath.llm_client.settings.constants import ApiFlavor, ApiType, RoutingMode, VendorType
16+
from uipath.llm_client.settings.constants import (
17+
ApiFlavor,
18+
ApiType,
19+
ByomApiFlavor,
20+
RoutingMode,
21+
VendorType,
22+
)
1723

1824

1925
class UiPathAPIConfig(BaseModel):
@@ -238,6 +244,19 @@ def get_model_info(
238244
)
239245
]
240246

247+
# When multiple OpenAI entries remain (both chat-completions and responses
248+
# flavors discovered), prefer the Responses API.
249+
if len(matching_models) > 1:
250+
vendor = str(matching_models[0].get("vendor", "")).lower()
251+
if vendor == VendorType.OPENAI:
252+
responses_matches = [
253+
m
254+
for m in matching_models
255+
if m.get("apiFlavor") in (ApiFlavor.RESPONSES, ByomApiFlavor.OPENAI_RESPONSES)
256+
]
257+
if responses_matches:
258+
matching_models = responses_matches
259+
241260
if not matching_models:
242261
raise ValueError(
243262
f"Model {model_name} not found. "

tests/core/features/settings/test_llmgateway.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,3 +619,73 @@ def test_skips_validate_byo_model_for_uipath_owned(self, llmgw_env_vars):
619619
with patch.object(settings, "validate_byo_model") as mock_validate:
620620
settings.get_model_info("claude-3-opus")
621621
mock_validate.assert_not_called()
622+
623+
def test_prefers_responses_when_both_openai_flavors_available(self, llmgw_env_vars):
624+
"""When OpenAI discovery returns both chat-completions and responses entries,
625+
get_model_info returns the responses one."""
626+
models = [
627+
{
628+
"modelName": "custom-gpt",
629+
"vendor": "OpenAi",
630+
"apiFlavor": "OpenAiChatCompletions",
631+
"modelSubscriptionType": "BYO",
632+
"byomDetails": {
633+
"integrationServiceConnectionId": "conn-1",
634+
"availableOperationCodes": ["op1"],
635+
},
636+
},
637+
{
638+
"modelName": "custom-gpt",
639+
"vendor": "OpenAi",
640+
"apiFlavor": "OpenAiResponses",
641+
"modelSubscriptionType": "BYO",
642+
"byomDetails": {
643+
"integrationServiceConnectionId": "conn-1",
644+
"availableOperationCodes": ["op1"],
645+
},
646+
},
647+
]
648+
settings = self._make_settings(llmgw_env_vars, models=models)
649+
info = settings.get_model_info("custom-gpt", byo_connection_id="conn-1")
650+
assert info["apiFlavor"] == "OpenAiResponses"
651+
652+
def test_prefers_responses_with_plain_apiflavor_strings(self, llmgw_env_vars):
653+
"""Tie-break also recognises the routing-form apiFlavor values."""
654+
models = [
655+
{
656+
"modelName": "gpt-x",
657+
"vendor": "OpenAi",
658+
"apiFlavor": "chat-completions",
659+
"modelSubscriptionType": "UiPathOwned",
660+
},
661+
{
662+
"modelName": "gpt-x",
663+
"vendor": "OpenAi",
664+
"apiFlavor": "responses",
665+
"modelSubscriptionType": "UiPathOwned",
666+
},
667+
]
668+
settings = self._make_settings(llmgw_env_vars, models=models)
669+
info = settings.get_model_info("gpt-x")
670+
assert info["apiFlavor"] == "responses"
671+
672+
def test_no_responses_preference_for_non_openai(self, llmgw_env_vars):
673+
"""The responses preference should not fire for non-OpenAI vendors."""
674+
models = [
675+
{
676+
"modelName": "claude-x",
677+
"vendor": "Anthropic",
678+
"apiFlavor": "anthropic-claude",
679+
"modelSubscriptionType": "UiPathOwned",
680+
},
681+
{
682+
"modelName": "claude-x",
683+
"vendor": "Anthropic",
684+
"apiFlavor": "converse",
685+
"modelSubscriptionType": "UiPathOwned",
686+
},
687+
]
688+
settings = self._make_settings(llmgw_env_vars, models=models)
689+
info = settings.get_model_info("claude-x")
690+
# First entry wins (no preference logic for Anthropic)
691+
assert info["apiFlavor"] == "anthropic-claude"

tests/langchain/features/test_factory_function.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
from unittest.mock import MagicMock
2+
13
import pytest
24
from uipath_langchain_client.clients.normalized.chat_models import UiPathChat
35
from uipath_langchain_client.clients.normalized.embeddings import UiPathEmbeddings
46
from uipath_langchain_client.factory import get_chat_model, get_embedding_model
57

68
from tests.langchain.conftest import COMPLETION_MODEL_NAMES, EMBEDDING_MODEL_NAMES
7-
from uipath.llm_client.settings import UiPathBaseSettings
9+
from uipath.llm_client.settings import ApiFlavor, UiPathBaseSettings
810

911

1012
@pytest.mark.vcr
@@ -44,3 +46,85 @@ def test_get_embedding_model_custom_class(
4446
)
4547
assert embedding_model is not None
4648
assert isinstance(embedding_model, UiPathEmbeddings)
49+
50+
51+
class TestFactoryDefaultApiFlavor:
52+
"""Unit tests for the default api_flavor picked by the chat factory.
53+
54+
The factory returns concrete LangChain model classes whose construction is
55+
non-trivial. Instead of fully instantiating them, we patch the concrete
56+
classes with a sentinel that captures the kwargs the factory passes.
57+
"""
58+
59+
def _captured_kwargs(
60+
self,
61+
monkeypatch: pytest.MonkeyPatch,
62+
model_info: dict,
63+
**factory_kwargs,
64+
) -> dict:
65+
settings = MagicMock()
66+
settings.get_model_info.return_value = model_info
67+
captured: dict = {}
68+
69+
class _StubModel:
70+
def __init__(self, **kwargs):
71+
captured.update(kwargs)
72+
73+
monkeypatch.setattr(
74+
"uipath_langchain_client.clients.openai.chat_models.UiPathChatOpenAI",
75+
_StubModel,
76+
)
77+
monkeypatch.setattr(
78+
"uipath_langchain_client.clients.openai.chat_models.UiPathAzureChatOpenAI",
79+
_StubModel,
80+
)
81+
get_chat_model(
82+
model_name=model_info["modelName"],
83+
client_settings=settings,
84+
**factory_kwargs,
85+
)
86+
return captured
87+
88+
def test_openai_chat_defaults_to_responses_when_no_flavor_discovered(
89+
self, monkeypatch: pytest.MonkeyPatch
90+
):
91+
"""UiPath-owned OpenAI (apiFlavor=null) should default to the Responses API."""
92+
captured = self._captured_kwargs(
93+
monkeypatch,
94+
{
95+
"modelName": "gpt-4o",
96+
"vendor": "OpenAi",
97+
"apiFlavor": None,
98+
"modelFamily": "OpenAi",
99+
},
100+
)
101+
assert captured["api_flavor"] == ApiFlavor.RESPONSES
102+
103+
def test_openai_chat_respects_user_api_flavor_override(self, monkeypatch: pytest.MonkeyPatch):
104+
"""Explicit api_flavor from the caller still wins over the default."""
105+
captured = self._captured_kwargs(
106+
monkeypatch,
107+
{
108+
"modelName": "gpt-4o",
109+
"vendor": "OpenAi",
110+
"apiFlavor": None,
111+
"modelFamily": "OpenAi",
112+
},
113+
api_flavor=ApiFlavor.CHAT_COMPLETIONS,
114+
)
115+
assert captured["api_flavor"] == ApiFlavor.CHAT_COMPLETIONS
116+
117+
def test_openai_chat_respects_discovered_byom_chat_completions(
118+
self, monkeypatch: pytest.MonkeyPatch
119+
):
120+
"""BYOM-discovered chat-completions still maps to chat-completions."""
121+
captured = self._captured_kwargs(
122+
monkeypatch,
123+
{
124+
"modelName": "custom-gpt",
125+
"vendor": "OpenAi",
126+
"apiFlavor": "OpenAiChatCompletions",
127+
"modelFamily": None,
128+
},
129+
)
130+
assert captured["api_flavor"] == ApiFlavor.CHAT_COMPLETIONS

0 commit comments

Comments
 (0)