diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index e6f40ae0..c96658d1 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.14.1] - 2026-06-23 + +### Fixed +- `WrappedBotoClient` (the httpx-backed Bedrock shim) now calls `raise_for_status()` in `converse`, `invoke_model`, and the streaming generator before reading the response. Previously a non-2xx gateway response (e.g. 403 License-not-available) was parsed as a normal result and handed to `langchain_aws`, which raised a misleading `ValueError("No 'output' key found in the response from the Bedrock Converse API ... misconfiguration of endpoint or region")` — the real status code and `detail` were lost. Gateway HTTP errors now surface as the patched `UiPathAPIError` subclass (e.g. `UiPathPermissionDeniedError`), matching the OpenAI and Vertex paths. For streaming responses the error body is read first so the typed exception retains its `detail`. + ## [1.14.0] - 2026-06-15 ### Added 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 6e980c9f..fd16af42 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.14.1" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py index 72e78e51..67a93a45 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py @@ -57,6 +57,12 @@ def _stream_generator( if self.httpx_client is None: raise ValueError("httpx_client is not set") with self.httpx_client.stream("POST", "/", json=_serialize_bytes(request_body)) as response: + if response.is_error: + # The gateway returns a non-streamed JSON error body; read it so + # the patched raise_for_status surfaces it (with detail) instead + # of the EventStreamBuffer choking on a non-event payload. + response.read() + response.raise_for_status() buffer = EventStreamBuffer() for chunk in response.iter_bytes(): buffer.add_data(chunk) @@ -71,12 +77,12 @@ def _stream_generator( def invoke_model(self, **kwargs: Any) -> Any: if self.httpx_client is None: raise ValueError("httpx_client is not set") - return { - "body": self.httpx_client.post( - "/", - json=json.loads(kwargs.get("body", "{}")), - ) - } + response = self.httpx_client.post( + "/", + json=json.loads(kwargs.get("body", "{}")), + ) + response.raise_for_status() + return {"body": response} def invoke_model_with_response_stream(self, **kwargs: Any) -> Any: return {"body": self._stream_generator(json.loads(kwargs.get("body", "{}")))} @@ -90,7 +96,7 @@ def converse( ) -> Any: if self.httpx_client is None: raise ValueError("httpx_client is not set") - return self.httpx_client.post( + response = self.httpx_client.post( "/", json=_serialize_bytes( { @@ -99,7 +105,9 @@ def converse( **params, } ), - ).json() + ) + response.raise_for_status() + return response.json() def converse_stream( self, diff --git a/tests/langchain/clients/bedrock/test_wrapped_boto_client.py b/tests/langchain/clients/bedrock/test_wrapped_boto_client.py new file mode 100644 index 00000000..47189587 --- /dev/null +++ b/tests/langchain/clients/bedrock/test_wrapped_boto_client.py @@ -0,0 +1,60 @@ +"""Unit tests for ``WrappedBotoClient`` HTTP error surfacing. + +The shim talks to the LLM Gateway over httpx instead of AWS. It must call +``raise_for_status()`` so gateway HTTP errors (e.g. 403 License-not-available) +propagate as exceptions, rather than being parsed as a normal result and then +mis-reported downstream (langchain_aws raises a misleading "No 'output' key" +``ValueError`` when the response lacks the expected fields). +""" + +import json + +import httpx +import pytest +from uipath_langchain_client.clients.bedrock.utils import WrappedBotoClient + +_ERROR_BODY = { + "title": "License not available", + "status": 403, + "detail": "License not available for LLM usage.", +} + + +def _wrapped(handler: object) -> WrappedBotoClient: + transport = httpx.MockTransport(handler) # type: ignore[arg-type] + return WrappedBotoClient( + httpx_client=httpx.Client(transport=transport, base_url="http://gateway") + ) + + +def test_converse_raises_on_http_error() -> None: + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + with pytest.raises(httpx.HTTPStatusError): + client.converse(messages=[{"role": "user", "content": [{"text": "hi"}]}]) + + +def test_converse_returns_body_on_success() -> None: + payload = {"output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}} + client = _wrapped(lambda request: httpx.Response(200, json=payload)) + assert client.converse(messages=[]) == payload + + +def test_invoke_model_raises_on_http_error() -> None: + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + with pytest.raises(httpx.HTTPStatusError): + client.invoke_model(body=json.dumps({"prompt": "hi"})) + + +def test_converse_stream_raises_on_http_error() -> None: + # The generator defers work until iterated, so the error surfaces on consume. + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + stream = client.converse_stream(messages=[])["stream"] + with pytest.raises(httpx.HTTPStatusError): + list(stream) + + +def test_invoke_model_with_response_stream_raises_on_http_error() -> None: + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + stream = client.invoke_model_with_response_stream(body=json.dumps({"prompt": "hi"}))["body"] + with pytest.raises(httpx.HTTPStatusError): + list(stream)