diff --git a/.github/scripts/inject_uv_overrides.py b/.github/scripts/inject_uv_overrides.py new file mode 100644 index 00000000..2e8dce30 --- /dev/null +++ b/.github/scripts/inject_uv_overrides.py @@ -0,0 +1,55 @@ +"""Point a downstream uv project at locally built uipath* wheels. + +Appends ``tool.uv.override-dependencies`` entries for every ``uipath*`` wheel +found under ``$WHEELS_DIR`` (default: ``./wheels``) to the ``pyproject.toml`` +given as ``argv[1]``. uv overrides bypass version specifiers, so a downstream +cap like ``uipath-langchain-client<1.19.0`` cannot mask the new code, and they +apply to project commands (``uv sync``/``uv add``) where the ``UV_OVERRIDE`` +env var is silently ignored. + +An override replaces the whole requirement, dropping any extras the downstream +graph asked for (e.g. ``uipath-langchain-client[openai]``), so EXTRAS pins the +union of extras each overridden package must keep providing. +""" + +import glob +import os +import pathlib +import re +import sys + +EXTRAS: dict[str, str] = { + "uipath-langchain-client": "all", +} + + +def main() -> None: + pyproject = pathlib.Path(sys.argv[1]) + wheels = pathlib.Path(os.environ.get("WHEELS_DIR", "wheels")).resolve() + + entries: list[str] = [] + for whl in sorted(glob.glob(str(wheels / "**" / "*.whl"), recursive=True)): + # Wheel filename is ``{distribution}-{version}-...whl`` where the + # distribution escapes hyphens to underscores (uipath_llm_client -> + # uipath-llm-client). + dist = pathlib.Path(whl).name.split("-", 1)[0].replace("_", "-") + if not dist.startswith("uipath"): + continue + extra = f"[{EXTRAS[dist]}]" if dist in EXTRAS else "" + entries.append(f' "{dist}{extra} @ {pathlib.Path(whl).as_uri()}",') + + if not entries: + raise SystemExit(f"no uipath wheels found under {wheels}") + + block = "override-dependencies = [\n" + "\n".join(entries) + "\n]\n" + text = pyproject.read_text() + if re.search(r"^\[tool\.uv\]$", text, flags=re.M): + text = re.sub(r"^\[tool\.uv\]\n", "[tool.uv]\n" + block, text, count=1, flags=re.M) + else: + text = text.rstrip() + "\n\n[tool.uv]\n" + block + pyproject.write_text(text) + print(f"{pyproject}:\n{block}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/test-downstream-langchain.yml b/.github/workflows/test-downstream-langchain.yml new file mode 100644 index 00000000..cd3ff29b --- /dev/null +++ b/.github/workflows/test-downstream-langchain.yml @@ -0,0 +1,148 @@ +# Downstream gate: run uipath-langchain-python's tests against wheels built +# from this PR, so a client change that breaks the downstream repo cannot merge +# (and therefore cannot auto-publish to PyPI via cd.yml / cd-langchain.yml). +# +# Requires repo secrets ALPHA_TEST_CLIENT_ID / ALPHA_TEST_CLIENT_SECRET / +# ALPHA_BASE_URL (same values as uipath-langchain-python's integration tests). +# The `skip:downstream` label bypasses the gate for emergencies (e.g. the +# downstream main is broken for unrelated reasons). +name: Downstream integration + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: downstream-${{ github.ref }} + cancel-in-progress: true + +env: + DOWNSTREAM_REPO: UiPath/uipath-langchain-python + DOWNSTREAM_REF: main + +jobs: + detect-changes: + runs-on: uipath-ubuntu-latest + outputs: + run_downstream: ${{ steps.detect.outputs.changed }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + - name: Detect package source changes + id: detect + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + changed=$(git diff --name-only "$BASE_SHA...$HEAD_SHA" \ + | grep -cE '^(src/|packages/uipath_langchain_client/(src/|pyproject\.toml)|pyproject\.toml|uv\.lock)' \ + || true) + echo "changed=$([ "$changed" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "Package source files changed: $changed" + + build-wheels: + needs: detect-changes + if: needs.detect-changes.outputs.run_downstream == 'true' && !contains(github.event.pull_request.labels.*.name, 'skip:downstream') + runs-on: uipath-ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: "0.9.27" + + - name: Build wheels from the PR + run: | + uv build --out-dir wheels + uv build --package uipath-langchain-client --out-dir wheels + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: client-wheels + path: wheels/*.whl + + integration-tests: + needs: build-wheels + runs-on: uipath-ubuntu-latest + timeout-minutes: 10 + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm + env: + UIPATH_JOB_KEY: "3a03d5cb-fa21-4021-894d-a8e2eda0afe0" + UIPATH_TRACING_ENABLED: false + strategy: + fail-fast: false + matrix: + # Only the testcases that exercise the LLM client; the downstream repo's + # own CI covers the SDK-scaffolding testcases and the full env matrix. + testcase: [chat-models, multimodal-invoke] + environment: [alpha] + + name: "${{ matrix.testcase }} / ${{ matrix.environment }}" + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + path: client + sparse-checkout: .github/scripts + + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + repository: ${{ env.DOWNSTREAM_REPO }} + ref: ${{ env.DOWNSTREAM_REF }} + path: downstream + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: client-wheels + path: wheels + + - name: Point testcase at the PR wheels + run: | + python3 client/.github/scripts/inject_uv_overrides.py \ + "downstream/testcases/${{ matrix.testcase }}/pyproject.toml" + + - name: Run testcase + working-directory: downstream/testcases/${{ matrix.testcase }} + env: + CLIENT_ID: ${{ secrets.ALPHA_TEST_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.ALPHA_TEST_CLIENT_SECRET }} + BASE_URL: ${{ secrets.ALPHA_BASE_URL }} + run: | + # With empty credentials `uipath auth` falls back to the interactive + # browser flow and hangs until the job timeout - fail fast instead. + # POSIX sh, not bash: container-job steps run under `sh -e`. + if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ] || [ -z "$BASE_URL" ]; then + echo "::error::ALPHA_TEST_CLIENT_ID / ALPHA_TEST_CLIENT_SECRET / ALPHA_BASE_URL secrets are not configured on this repository. Copy them from uipath-langchain-python's integration setup." + exit 1 + fi + echo "Running testcase: ${{ matrix.testcase }} against ${{ matrix.environment }}" + bash run.sh + bash ../common/validate_output.sh + + downstream-gate: + needs: [detect-changes, integration-tests] + if: always() + runs-on: uipath-ubuntu-latest + steps: + - name: Evaluate gate + run: | + if [[ "${{ needs.detect-changes.outputs.run_downstream }}" != "true" ]]; then + echo "No package source changed - downstream tests not required." + exit 0 + fi + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'skip:downstream') }}" == "true" ]]; then + echo "skip:downstream label present - gate bypassed." + exit 0 + fi + if [[ "${{ needs.integration-tests.result }}" == "success" ]]; then + echo "Downstream green." + exit 0 + fi + echo "Downstream tests failed - this PR would break uipath-langchain-python." + exit 1 diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index adb94148..526b9589 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.18.4] - 2026-09-02 + +### Fixed +- `UiPathChatAnthropic._create`/`._acreate` now return raw responses (`with_raw_response`) to match langchain-anthropic 1.7.0, which calls `.parse()` on the result. Lifts the interim `<1.7.0` pin from 1.18.2: the `langchain-anthropic` bound on the `anthropic` and `bedrock` extras is now `>=1.7.0,<2.0.0`. +- `UiPathChatLiteLLM.stream()`/`.astream()` no longer silently fall back to non-streaming calls on langchain-core >= 1.4, which treats the `streaming=False` that ChatLiteLLM's validator marks as explicitly set as a hard streaming opt-out. + ## [1.18.3] - 2026-09-02 ### Added diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 8b3760ac..295b6ba1 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -17,12 +17,12 @@ google = [ "langchain-google-genai>=4.2.2,<5.0.0", ] anthropic = [ - "langchain-anthropic>=1.4.1,<1.7.0", + "langchain-anthropic>=1.7.0,<2.0.0", "anthropic[bedrock,vertex]>=0.96.0,<1.0.0", ] bedrock = [ "langchain-aws[anthropic]>=1.4.5,<2.0.0", - "langchain-anthropic>=1.4.1,<1.7.0", + "langchain-anthropic>=1.7.0,<2.0.0", "anthropic[bedrock]>=0.96.0,<1.0.0", ] vertexai = [ 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 f570ce1c..b91c30ed 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.18.3" +__version__ = "1.18.4" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/anthropic/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/anthropic/chat_models.py index c1c8d92c..54dd5a38 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/anthropic/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/anthropic/chat_models.py @@ -179,14 +179,18 @@ def _async_anthropic_client( case _: raise ValueError("Anthropic models are currently not hosted on any other provider") + # langchain-anthropic >= 1.7.0 expects _create/_acreate to return a raw-response + # wrapper (it calls .parse() on the result), so route through with_raw_response. @override def _create(self, payload: dict[str, Any]) -> Any: if "betas" in payload: - return self._anthropic_client.beta.messages.create(**payload) - return self._anthropic_client.messages.create(**payload) + return self._anthropic_client.beta.messages.with_raw_response.create(**payload) + return self._anthropic_client.messages.with_raw_response.create(**payload) @override async def _acreate(self, payload: dict[str, Any]) -> Any: if "betas" in payload: - return await self._async_anthropic_client.beta.messages.create(**payload) - return await self._async_anthropic_client.messages.create(**payload) + return await self._async_anthropic_client.beta.messages.with_raw_response.create( + **payload + ) + return await self._async_anthropic_client.messages.with_raw_response.create(**payload) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/chat_models.py index 4bbc72b5..b779e253 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/chat_models.py @@ -83,6 +83,17 @@ class UiPathChatLiteLLM(UiPathBaseChatModel, ChatLiteLLM): # type: ignore[overr vendor_type: VendorType | str | None = Field(default=None, exclude=True) api_flavor: ApiFlavor | str | None = Field(default=None, exclude=True) + def __init__(self, **kwargs: Any) -> None: + streaming_explicitly_set = "streaming" in kwargs + super().__init__(**kwargs) + # ChatLiteLLM's before-validator materializes a value for every field, + # which marks `streaming` as explicitly set. langchain-core >= 1.4 + # treats an explicitly-set streaming=False as a hard opt-out, silently + # downgrading .stream()/.astream() to non-streaming invoke calls. + # Un-mark the field unless the caller actually passed it. + if not streaming_explicitly_set: + self.__pydantic_fields_set__.discard("streaming") + # Internal core client — handles discovery, HTTPHandler lifecycle, provider resolution _core: UiPathLiteLLM | None = None diff --git a/tests/langchain/clients/anthropic/test_raw_response_contract.py b/tests/langchain/clients/anthropic/test_raw_response_contract.py new file mode 100644 index 00000000..efab282f --- /dev/null +++ b/tests/langchain/clients/anthropic/test_raw_response_contract.py @@ -0,0 +1,290 @@ +"""Unit tests for the langchain-anthropic >= 1.7.0 raw-response contract. + +langchain-anthropic 1.7.0 changed `_create`/`_acreate` to return a raw-response +wrapper and calls `.parse()` on it in `_generate`/`_agenerate`/`_stream`/`_astream`. +These tests mock the HTTP transport and verify that the UiPath overrides (which +route through the UiPath-constructed SDK clients) satisfy that contract for +invoke, streaming, tool calling, structured output, and the `betas` payload +branch — i.e. no `AttributeError: ... 'parse'` and responses parse into +`AIMessage`s. +""" + +import json +from typing import Any + +import httpx +import pytest +from anthropic import Anthropic, AsyncAnthropic +from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage +from pydantic import BaseModel +from uipath_langchain_client.clients.anthropic.chat_models import UiPathChatAnthropic + +from uipath.llm_client.settings import UiPathBaseSettings + +MODEL_NAME = "claude-sonnet-4-6" + +TEXT_MESSAGE: dict[str, Any] = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": MODEL_NAME, + "content": [{"type": "text", "text": "Hello!"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 5}, +} + + +def _tool_use_message(name: str, args: dict[str, Any]) -> dict[str, Any]: + return { + **TEXT_MESSAGE, + "content": [{"type": "tool_use", "id": "toolu_test", "name": name, "input": args}], + "stop_reason": "tool_use", + } + + +_STREAM_EVENTS: list[dict[str, Any]] = [ + { + "type": "message_start", + "message": { + **TEXT_MESSAGE, + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hel"}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "lo!"}}, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 5}, + }, + {"type": "message_stop"}, +] + +SSE_BODY = "".join( + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in _STREAM_EVENTS +) + + +class _MockedTransport: + """httpx.MockTransport handler that records requests and serves canned responses. + + Streaming requests (``"stream": true`` in the body) get an SSE stream; other + requests get ``message`` as JSON. When the request declares tools, the + response is a ``tool_use`` block invoking the first tool with ``tool_args``. + """ + + def __init__(self, tool_args: dict[str, Any] | None = None) -> None: + self.requests: list[httpx.Request] = [] + self.tool_args = tool_args or {} + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + body = json.loads(request.content) + if body.get("stream"): + return httpx.Response( + 200, content=SSE_BODY, headers={"content-type": "text/event-stream"} + ) + if body.get("tools"): + message = _tool_use_message(body["tools"][0]["name"], self.tool_args) + else: + message = TEXT_MESSAGE + return httpx.Response( + 200, + json=message, + headers={"X-LangSmith-Gateway-Metadata": json.dumps({"provider": "anthropic"})}, + ) + + +def _mocked_chat( + client_settings: UiPathBaseSettings, + transport_handler: _MockedTransport, + **model_kwargs: Any, +) -> UiPathChatAnthropic: + """Build a UiPathChatAnthropic whose SDK clients run over a mock transport.""" + params: dict[str, Any] = { + "model": MODEL_NAME, + "settings": client_settings, + "model_details": {}, + "max_tokens": 1024, + **model_kwargs, + } + chat = UiPathChatAnthropic(**params) + transport = httpx.MockTransport(transport_handler) + # Shadow the cached properties with SDK clients over the mock transport, + # preserving the shape _create/_acreate rely on. + object.__setattr__( + chat, + "_anthropic_client", + Anthropic( + api_key="PLACEHOLDER", + base_url="https://mock.uipath.test", + max_retries=0, + http_client=httpx.Client(transport=transport), + ), + ) + object.__setattr__( + chat, + "_async_anthropic_client", + AsyncAnthropic( + api_key="PLACEHOLDER", + base_url="https://mock.uipath.test", + max_retries=0, + http_client=httpx.AsyncClient(transport=transport), + ), + ) + return chat + + +@pytest.fixture +def transport_handler() -> _MockedTransport: + return _MockedTransport(tool_args={"city": "Paris"}) + + +class Weather(BaseModel): + """Weather report for a city.""" + + city: str + + +class TestRawResponseContract: + def test_invoke( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + message = chat.invoke("Say hello") + assert isinstance(message, AIMessage) + assert message.text == "Hello!" + assert transport_handler.requests[-1].url.path == "/v1/messages" + assert "beta" not in str(transport_handler.requests[-1].url.query) + + async def test_ainvoke( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + message = await chat.ainvoke("Say hello") + assert isinstance(message, AIMessage) + assert message.text == "Hello!" + + def test_stream( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + chunks = list(chat.stream("Say hello")) + assert chunks + assert all(isinstance(chunk, AIMessageChunk) for chunk in chunks) + assert "".join(chunk.text for chunk in chunks) == "Hello!" + + async def test_astream( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + chunks = [chunk async for chunk in chat.astream("Say hello")] + assert chunks + assert all(isinstance(chunk, AIMessageChunk) for chunk in chunks) + assert "".join(chunk.text for chunk in chunks) == "Hello!" + + def test_tool_calling( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + message = chat.bind_tools([Weather]).invoke("Weather in Paris?") + assert isinstance(message, AIMessage) + assert len(message.tool_calls) == 1 + assert message.tool_calls[0]["name"] == "Weather" + assert message.tool_calls[0]["args"] == {"city": "Paris"} + + async def test_tool_calling_async( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + message = await chat.bind_tools([Weather]).ainvoke("Weather in Paris?") + assert isinstance(message, AIMessage) + assert message.tool_calls[0]["name"] == "Weather" + assert message.tool_calls[0]["args"] == {"city": "Paris"} + + def test_structured_output( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + result = chat.with_structured_output(Weather).invoke("Weather in Paris?") + assert isinstance(result, Weather) + assert result.city == "Paris" + + async def test_structured_output_async( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler) + result = await chat.with_structured_output(Weather).ainvoke("Weather in Paris?") + assert isinstance(result, Weather) + assert result.city == "Paris" + + +class TestBetasPayloadBranch: + """When the payload carries `betas`, requests must route through beta.messages.""" + + def test_invoke_routes_to_beta_endpoint( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat( + client_settings, transport_handler, betas=["token-efficient-tools-2025-02-19"] + ) + message = chat.invoke("Say hello") + assert isinstance(message, AIMessage) + assert message.text == "Hello!" + request = transport_handler.requests[-1] + assert request.url.path == "/v1/messages" + assert "beta=true" in str(request.url.query) + assert request.headers.get("anthropic-beta") == "token-efficient-tools-2025-02-19" + + async def test_ainvoke_routes_to_beta_endpoint( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat( + client_settings, transport_handler, betas=["token-efficient-tools-2025-02-19"] + ) + message = await chat.ainvoke("Say hello") + assert isinstance(message, AIMessage) + request = transport_handler.requests[-1] + assert "beta=true" in str(request.url.query) + assert request.headers.get("anthropic-beta") == "token-efficient-tools-2025-02-19" + + def test_stream_routes_to_beta_endpoint( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat( + client_settings, transport_handler, betas=["token-efficient-tools-2025-02-19"] + ) + chunks = list(chat.stream("Say hello")) + assert "".join(chunk.text for chunk in chunks) == "Hello!" + assert "beta=true" in str(transport_handler.requests[-1].url.query) + + +class TestGatewayMetadataExtraction: + """langchain-anthropic reads gateway metadata headers off the raw response. + + `_add_gateway_metadata` only runs when the API key marks a LangSmith gateway + (`lsv2_` prefix); use one to verify the raw wrapper still exposes headers. + """ + + def test_gateway_metadata_lands_in_generation_info( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler, anthropic_api_key="lsv2_test") + result = chat.generate([[HumanMessage("Say hello")]]) + generation_info = result.generations[0][0].generation_info + assert generation_info is not None + assert generation_info["lc_gateway_metadata"] == {"provider": "anthropic"} + + async def test_gateway_metadata_lands_in_generation_info_async( + self, client_settings: UiPathBaseSettings, transport_handler: _MockedTransport + ) -> None: + chat = _mocked_chat(client_settings, transport_handler, anthropic_api_key="lsv2_test") + result = await chat.agenerate([[HumanMessage("Say hello")]]) + generation_info = result.generations[0][0].generation_info + assert generation_info is not None + assert generation_info["lc_gateway_metadata"] == {"provider": "anthropic"} diff --git a/tests/langchain/clients/litellm/test_streaming_dispatch.py b/tests/langchain/clients/litellm/test_streaming_dispatch.py new file mode 100644 index 00000000..82ca4b51 --- /dev/null +++ b/tests/langchain/clients/litellm/test_streaming_dispatch.py @@ -0,0 +1,50 @@ +"""Regression tests for streaming dispatch under langchain-core >= 1.4. + +ChatLiteLLM's before-validator materializes a value for every field, marking +``streaming`` as explicitly set. langchain-core >= 1.4 treats an explicitly-set +``streaming=False`` as a hard opt-out, so ``.stream()``/``.astream()`` silently +fall back to non-streaming ``invoke`` calls. ``UiPathChatLiteLLM`` un-marks the +field unless the caller actually passed it. +""" + +from typing import Any + +import pytest +from uipath_langchain_client.clients.litellm.chat_models import UiPathChatLiteLLM + +from uipath.llm_client.settings import UiPathBaseSettings + + +@pytest.fixture +def chat_factory(client_settings: UiPathBaseSettings, monkeypatch: pytest.MonkeyPatch) -> Any: + monkeypatch.setattr( + type(client_settings), + "get_model_info", + lambda self, *args, **kwargs: {"vendor": "openai"}, + ) + + def _build(**kwargs: Any) -> UiPathChatLiteLLM: + return UiPathChatLiteLLM( + model="gpt-5.2-2025-12-11", settings=client_settings, model_details={}, **kwargs + ) + + return _build + + +def test_stream_not_disabled_by_default(chat_factory: Any) -> None: + chat = chat_factory() + assert "streaming" not in chat.model_fields_set + assert chat._should_stream(async_api=False, stream=True) + assert chat._should_stream(async_api=True, stream=True) + + +def test_explicit_streaming_false_still_respected(chat_factory: Any) -> None: + chat = chat_factory(streaming=False) + assert "streaming" in chat.model_fields_set + assert not chat._should_stream(async_api=False, stream=True) + + +def test_explicit_streaming_true_kept(chat_factory: Any) -> None: + chat = chat_factory(streaming=True) + assert "streaming" in chat.model_fields_set + assert chat._should_stream(async_api=False) diff --git a/uv.lock b/uv.lock index eb5586a4..8554d0c6 100644 --- a/uv.lock +++ b/uv.lock @@ -166,7 +166,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.96.0" +version = "0.125.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -178,9 +178,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/7e/672f533dee813028d2c699bfd2a7f52c9118d7353680d9aa44b9e23f717f/anthropic-0.96.0.tar.gz", hash = "sha256:9de947b737f39452f68aa520f1c2239d44119c9b73b0fb6d4e6ca80f00279ee6", size = 658210, upload-time = "2026-04-16T14:28:02.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/f8/6f0560884b5363848347bd640b6c1d04abc25e7aa61787a232f790c6b60a/anthropic-0.125.0.tar.gz", hash = "sha256:e0cdd336580cb7411c1cdab69f80973e9bf4bff7f8e08141811d46307d45c682", size = 1112593, upload-time = "2026-08-19T22:00:42.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/5a/72f33204064b6e87601a71a6baf8d855769f8a0c1eaae8d06a1094872371/anthropic-0.96.0-py3-none-any.whl", hash = "sha256:9a6e335a354602a521cd9e777e92bfd46ba6e115bf9bbfe6135311e8fb2015b2", size = 635930, upload-time = "2026-04-16T14:28:01.436Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1a/b1bd30cda3790557e8791bec5922a6ec8fabb6fa8b008c76a39cf7be6152/anthropic-0.125.0-py3-none-any.whl", hash = "sha256:3486013602eca76d8b12540764e53654f02cf4951110bca86cf06e67428a9f21", size = 1184067, upload-time = "2026-08-19T22:00:44.596Z" }, ] [package.optional-dependencies] @@ -1512,16 +1512,16 @@ wheels = [ [[package]] name = "langchain-anthropic" -version = "1.4.1" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/19/55e0d3548a4d85ccee630fcfc0979af54ff4ea4f39ab0ed1ed3c6bf25f4e/langchain_anthropic-1.4.1.tar.gz", hash = "sha256:e17d027091438620e35ff2f06aefdfd63c8dcdf6abc606009ddfe3c764f2bc2e", size = 676043, upload-time = "2026-04-17T14:26:17.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/fc/52f6d1d6069bafb08626e204c89c49c8dd4a536eedbb94f0b7e78668594d/langchain_anthropic-1.7.0.tar.gz", hash = "sha256:d48e3c118ff8d3eea83f17b50234a2d2ff491a2375d565f212eb990e7e3856cb", size = 750068, upload-time = "2026-08-27T15:23:59.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/0a/20625dfea38a26e8b43654e18cafbc3595e7dc223da80f23d4fa8451dc1d/langchain_anthropic-1.4.1-py3-none-any.whl", hash = "sha256:5a48afbb2b1bad9c46badaccc8e23b0dd7ae07b7583f76bac21ddb3dac831efd", size = 49020, upload-time = "2026-04-17T14:26:15.989Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/e4367713080bc750e1dba845409c058f196543d1a0dc99683e2ef062f581/langchain_anthropic-1.7.0-py3-none-any.whl", hash = "sha256:68b34369aa01dad0c67bc690b8c47e09d06bd30fb28f320ad19ddc71c4445dc0", size = 60475, upload-time = "2026-08-27T15:23:57.989Z" }, ] [[package]] @@ -1570,10 +1570,12 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.3.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "httpx" }, { name = "jsonpatch" }, + { name = "langchain-protocol" }, { name = "langsmith" }, { name = "packaging" }, { name = "pydantic" }, @@ -1582,9 +1584,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/12/aff76ca89c219ebe6f9dd3c5dbc4e3b1cf5450e9fc7037dccad23d45cd7a/langchain_core-1.6.1.tar.gz", hash = "sha256:1b156cb395aac4f009a8a1b38a574c7d948fe2d5f74c96e0d8a5017b4149e04f", size = 1003359, upload-time = "2026-08-27T19:31:14.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/f50dd65673c819aa33d3c34df58c115dbb6ec627d19f93e6e401dd0fc8d7/langchain_core-1.6.1-py3-none-any.whl", hash = "sha256:954a84132a5cb0435d27b910e336347b6744ecc18fbeef1e2de7029a0959841a", size = 571478, upload-time = "2026-08-27T19:31:13.34Z" }, ] [[package]] @@ -1670,6 +1672,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/7b/e8c3beeab0ca042529533072ebee69c66327c1805b3133531b58c422baab/langchain_openai-1.2.0-py3-none-any.whl", hash = "sha256:b3ed14dc48e40890605136f26c6b07e8f293987d95e734ab67cbfa572c523456", size = 98592, upload-time = "2026-04-23T00:43:34.135Z" }, ] +[[package]] +name = "langchain-protocol" +version = "0.0.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/56/913599f2f9cec8524868929f12d72b2ede377a6056ca8a40a32bdadfa535/langchain_protocol-0.0.19.tar.gz", hash = "sha256:79d90a1425122ac87e8052e2ec054fbd09c3edbf341bdfb6397112a495c7bf8c", size = 6265, upload-time = "2026-08-26T21:12:00.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/c9/f6cbf357d48ccbd18bb394433b1fd7ad9be004eed9377ad08bb85777e5e6/langchain_protocol-0.0.19-py3-none-any.whl", hash = "sha256:4cdf879a492a35980fd859ae792d3c65458ccaae504e183c9a10d7eac1f0720f", size = 7327, upload-time = "2026-08-26T21:11:59.781Z" }, +] + [[package]] name = "langchain-tests" version = "1.1.6" @@ -3774,9 +3788,9 @@ requires-dist = [ { name = "anthropic", extras = ["bedrock", "vertex"], marker = "extra == 'all'", specifier = ">=0.96.0,<1.0.0" }, { name = "anthropic", extras = ["bedrock", "vertex"], marker = "extra == 'anthropic'", specifier = ">=0.96.0,<1.0.0" }, { name = "langchain", specifier = ">=1.2.15,<2.0.0" }, - { name = "langchain-anthropic", marker = "extra == 'all'", specifier = ">=1.4.1,<1.7.0" }, - { name = "langchain-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.4.1,<1.7.0" }, - { name = "langchain-anthropic", marker = "extra == 'bedrock'", specifier = ">=1.4.1,<1.7.0" }, + { name = "langchain-anthropic", marker = "extra == 'all'", specifier = ">=1.7.0,<2.0.0" }, + { name = "langchain-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.7.0,<2.0.0" }, + { name = "langchain-anthropic", marker = "extra == 'bedrock'", specifier = ">=1.7.0,<2.0.0" }, { name = "langchain-aws", extras = ["anthropic"], marker = "extra == 'all'", specifier = ">=1.4.5,<2.0.0" }, { name = "langchain-aws", extras = ["anthropic"], marker = "extra == 'bedrock'", specifier = ">=1.4.5,<2.0.0" }, { name = "langchain-azure-ai", marker = "extra == 'all'", specifier = ">=1.2.2,<2.0.0" },