Skip to content

Commit 352f28a

Browse files
fix: unicodeEncodeError when UIPATH_PROCESS_KEY contains non-ASCII characters (#642)
1 parent 37466ba commit 352f28a

5 files changed

Lines changed: 141 additions & 4 deletions

File tree

src/uipath_langchain/_utils/_request_mixin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import time
66
from typing import Any, AsyncIterator, Dict, Iterator, Mapping
7+
from urllib.parse import quote
78

89
import httpx
910
import openai
@@ -79,7 +80,7 @@ class UiPathRequestMixin(BaseModel):
7980
default_headers: Mapping[str, str] | None = {
8081
"X-UiPath-Streaming-Enabled": "false",
8182
"X-UiPath-JobKey": os.getenv("UIPATH_JOB_KEY", ""),
82-
"X-UiPath-ProcessKey": os.getenv("UIPATH_PROCESS_KEY", ""),
83+
"X-UiPath-ProcessKey": quote(os.getenv("UIPATH_PROCESS_KEY", ""), safe=""),
8384
}
8485
model_name: str | None = Field(
8586
default_factory=lambda: os.getenv(

src/uipath_langchain/chat/bedrock.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
from collections.abc import Iterator
44
from typing import Any, Optional
5+
from urllib.parse import quote
56

67
from langchain_core.callbacks import CallbackManagerForLLMRun
78
from langchain_core.messages import BaseMessage
@@ -143,7 +144,7 @@ def _modify_request(self, request, **kwargs):
143144
if job_key:
144145
headers["X-UiPath-JobKey"] = job_key
145146
if process_key:
146-
headers["X-UiPath-ProcessKey"] = process_key
147+
headers["X-UiPath-ProcessKey"] = quote(process_key, safe="")
147148

148149
request.headers.update(headers)
149150

src/uipath_langchain/chat/openai.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import os
33
from typing import Optional
4+
from urllib.parse import quote
45

56
import httpx
67
from langchain_openai import AzureChatOpenAI
@@ -162,7 +163,7 @@ def _build_headers(self, token: str) -> dict[str, str]:
162163
if job_key := os.getenv("UIPATH_JOB_KEY"):
163164
headers["X-UiPath-JobKey"] = job_key
164165
if process_key := os.getenv("UIPATH_PROCESS_KEY"):
165-
headers["X-UiPath-ProcessKey"] = process_key
166+
headers["X-UiPath-ProcessKey"] = quote(process_key, safe="")
166167

167168
# Allow extra_headers to override defaults
168169
headers.update(self._extra_headers)

src/uipath_langchain/chat/vertex.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
from collections.abc import AsyncIterator, Iterator
44
from typing import Any, Optional
5+
from urllib.parse import quote
56

67
import httpx
78
from langchain_core.callbacks import (
@@ -266,7 +267,7 @@ def _build_headers(
266267
if job_key := os.getenv("UIPATH_JOB_KEY"):
267268
headers["X-UiPath-JobKey"] = job_key
268269
if process_key := os.getenv("UIPATH_PROCESS_KEY"):
269-
headers["X-UiPath-ProcessKey"] = process_key
270+
headers["X-UiPath-ProcessKey"] = quote(process_key, safe="")
270271
return headers
271272

272273
@staticmethod

tests/chat/test_header_encoding.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import os
2+
from unittest.mock import MagicMock, patch
3+
from urllib.parse import quote
4+
5+
import pytest
6+
7+
from uipath_langchain.chat.openai import UiPathChatOpenAI
8+
9+
NON_ASCII_PROCESS_KEY = "Solution.17.agent.GetCompanyIdAgent-請-test"
10+
ASCII_PROCESS_KEY = "Solution.17.agent.MyAgent-test"
11+
12+
BASE_ENV = {
13+
"UIPATH_URL": "https://cloud.uipath.com/org/tenant",
14+
"UIPATH_ORGANIZATION_ID": "org-id",
15+
"UIPATH_TENANT_ID": "tenant-id",
16+
"UIPATH_ACCESS_TOKEN": "test-token",
17+
}
18+
19+
20+
class TestOpenAIHeaderEncoding:
21+
"""Verify UiPathChatOpenAI percent-encodes non-ASCII header values."""
22+
23+
def _build_headers_with_process_key(self, process_key: str) -> dict[str, str]:
24+
env = {**BASE_ENV, "UIPATH_PROCESS_KEY": process_key}
25+
with patch.dict(os.environ, env, clear=False):
26+
obj = object.__new__(UiPathChatOpenAI)
27+
obj._agenthub_config = None
28+
obj._byo_connection_id = None
29+
obj._extra_headers = {}
30+
return obj._build_headers("fake-token")
31+
32+
def test_ascii_process_key_unchanged(self) -> None:
33+
headers = self._build_headers_with_process_key(ASCII_PROCESS_KEY)
34+
assert headers["X-UiPath-ProcessKey"] == quote(ASCII_PROCESS_KEY, safe="")
35+
36+
def test_non_ascii_process_key_encoded(self) -> None:
37+
headers = self._build_headers_with_process_key(NON_ASCII_PROCESS_KEY)
38+
value = headers["X-UiPath-ProcessKey"]
39+
assert "請" not in value
40+
assert value == quote(NON_ASCII_PROCESS_KEY, safe="")
41+
assert "%E8%AB%8B" in value
42+
43+
def test_header_value_is_ascii_safe(self) -> None:
44+
headers = self._build_headers_with_process_key(NON_ASCII_PROCESS_KEY)
45+
value = headers["X-UiPath-ProcessKey"]
46+
value.encode("ascii")
47+
48+
def test_missing_process_key_omitted(self) -> None:
49+
env = {**BASE_ENV}
50+
env.pop("UIPATH_PROCESS_KEY", None)
51+
with patch.dict(os.environ, env, clear=True):
52+
obj = object.__new__(UiPathChatOpenAI)
53+
obj._agenthub_config = None
54+
obj._byo_connection_id = None
55+
obj._extra_headers = {}
56+
headers = obj._build_headers("fake-token")
57+
assert "X-UiPath-ProcessKey" not in headers
58+
59+
60+
class TestVertexHeaderEncoding:
61+
"""Verify UiPathChatVertex._build_headers percent-encodes non-ASCII header values."""
62+
63+
@pytest.fixture(autouse=True)
64+
def _skip_if_no_google(self) -> None:
65+
pytest.importorskip("google.genai", reason="google-genai not installed")
66+
67+
def test_non_ascii_process_key_encoded(self) -> None:
68+
from uipath_langchain.chat.vertex import UiPathChatVertex
69+
70+
env = {**BASE_ENV, "UIPATH_PROCESS_KEY": NON_ASCII_PROCESS_KEY}
71+
with patch.dict(os.environ, env, clear=False):
72+
headers = UiPathChatVertex._build_headers("fake-token")
73+
value = headers["X-UiPath-ProcessKey"]
74+
assert "請" not in value
75+
assert "%E8%AB%8B" in value
76+
value.encode("ascii")
77+
78+
def test_ascii_process_key_unchanged(self) -> None:
79+
from uipath_langchain.chat.vertex import UiPathChatVertex
80+
81+
env = {**BASE_ENV, "UIPATH_PROCESS_KEY": ASCII_PROCESS_KEY}
82+
with patch.dict(os.environ, env, clear=False):
83+
headers = UiPathChatVertex._build_headers("fake-token")
84+
assert headers["X-UiPath-ProcessKey"] == quote(ASCII_PROCESS_KEY, safe="")
85+
86+
87+
class TestBedrockHeaderEncoding:
88+
"""Verify AwsBedrockCompletionsPassthroughClient percent-encodes non-ASCII header values."""
89+
90+
def test_non_ascii_process_key_encoded(self) -> None:
91+
pytest.importorskip("botocore", reason="botocore not installed")
92+
from uipath_langchain.chat.bedrock import AwsBedrockCompletionsPassthroughClient
93+
94+
env = {**BASE_ENV, "UIPATH_PROCESS_KEY": NON_ASCII_PROCESS_KEY}
95+
with (
96+
patch.dict(os.environ, env, clear=False),
97+
patch(
98+
"uipath_langchain.chat.bedrock.boto3.client", return_value=MagicMock()
99+
),
100+
):
101+
client = AwsBedrockCompletionsPassthroughClient(
102+
model="test-model",
103+
token="fake-token",
104+
api_flavor="converse",
105+
)
106+
request = MagicMock()
107+
request.url = "https://example.com/converse"
108+
request.headers = {}
109+
client._modify_request(request)
110+
111+
value = request.headers["X-UiPath-ProcessKey"]
112+
assert "請" not in value
113+
assert "%E8%AB%8B" in value
114+
value.encode("ascii")
115+
116+
117+
class TestRequestMixinHeaderEncoding:
118+
"""Verify UiPathRequestMixin default_headers percent-encodes non-ASCII values."""
119+
120+
def test_non_ascii_process_key_encoded_in_defaults(self) -> None:
121+
env = {**BASE_ENV, "UIPATH_PROCESS_KEY": NON_ASCII_PROCESS_KEY}
122+
with patch.dict(os.environ, env, clear=False):
123+
import importlib
124+
125+
import uipath_langchain._utils._request_mixin as mod
126+
127+
importlib.reload(mod)
128+
value = mod.UiPathRequestMixin.model_fields["default_headers"].default[
129+
"X-UiPath-ProcessKey"
130+
]
131+
assert "請" not in value
132+
assert "%E8%AB%8B" in value
133+
value.encode("ascii")

0 commit comments

Comments
 (0)