Skip to content

Commit 887b719

Browse files
authored
feat(toolbox-langchain): Add client headers to Toolbox (#264)
* add headers * added tests * lint * add docs
1 parent fecbf3d commit 887b719

File tree

5 files changed

+168
-4
lines changed

5 files changed

+168
-4
lines changed

packages/toolbox-langchain/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ applications, enabling advanced orchestration and interaction with GenAI models.
2020
- [Represent Tools as Nodes](#represent-tools-as-nodes)
2121
- [Connect Tools with LLM](#connect-tools-with-llm)
2222
- [Manual usage](#manual-usage)
23+
- [Client to Server Authentication](#client-to-server-authentication)
24+
- [When is Client-to-Server Authentication Needed?](#when-is-client-to-server-authentication-needed)
25+
- [How it works](#how-it-works)
26+
- [Configuration](#configuration)
27+
- [Authenticating with Google Cloud Servers](#authenticating-with-google-cloud-servers)
28+
- [Step by Step Guide for Cloud Run](#step-by-step-guide-for-cloud-run)
2329
- [Authenticating Tools](#authenticating-tools)
2430
- [Supported Authentication Mechanisms](#supported-authentication-mechanisms)
2531
- [Configure Tools](#configure-tools)
@@ -186,6 +192,87 @@ result = tools[0].invoke({"name": "Alice", "age": 30})
186192
This is useful for testing tools or when you need precise control over tool
187193
execution outside of an agent framework.
188194

195+
## Client to Server Authentication
196+
197+
This section describes how to authenticate the ToolboxClient itself when
198+
connecting to a Toolbox server instance that requires authentication. This is
199+
crucial for securing your Toolbox server endpoint, especially when deployed on
200+
platforms like Cloud Run, GKE, or any environment where unauthenticated access is restricted.
201+
202+
This client-to-server authentication ensures that the Toolbox server can verify the identity of the client making the request before any tool is loaded or called. It is different from [Authenticating Tools](#authenticating-tools), which deals with providing credentials for specific tools within an already connected Toolbox session.
203+
204+
### When is Client-to-Server Authentication Needed?
205+
206+
You'll need this type of authentication if your Toolbox server is configured to deny unauthenticated requests. For example:
207+
208+
- Your Toolbox server is deployed on Cloud Run and configured to "Require authentication."
209+
- Your server is behind an Identity-Aware Proxy (IAP) or a similar authentication layer.
210+
- You have custom authentication middleware on your self-hosted Toolbox server.
211+
212+
Without proper client authentication in these scenarios, attempts to connect or
213+
make calls (like `load_tool`) will likely fail with `Unauthorized` errors.
214+
215+
### How it works
216+
217+
The `ToolboxClient` (and `ToolboxSyncClient`) allows you to specify functions (or coroutines for the async client) that dynamically generate HTTP headers for every request sent to the Toolbox server. The most common use case is to add an Authorization header with a bearer token (e.g., a Google ID token).
218+
219+
These header-generating functions are called just before each request, ensuring
220+
that fresh credentials or header values can be used.
221+
222+
### Configuration
223+
224+
You can configure these dynamic headers in two ways:
225+
226+
1. **During Client Initialization**
227+
228+
```python
229+
from toolbox_langchain import ToolboxClient
230+
231+
client = ToolboxClient("toolbox-url", headers={"header1": header1_getter, "header2": header2_getter, ...})
232+
```
233+
234+
1. **After Client Initialization**
235+
236+
```python
237+
from toolbox_langchain import ToolboxClient
238+
239+
client = ToolboxClient("toolbox-url")
240+
client.add_headers({"header1": header1_getter, "header2": header2_getter, ...})
241+
```
242+
243+
### Authenticating with Google Cloud Servers
244+
245+
For Toolbox servers hosted on Google Cloud (e.g., Cloud Run) and requiring
246+
`Google ID token` authentication, the helper module
247+
[auth_methods](src/toolbox_core/auth_methods.py) provides utility functions.
248+
249+
### Step by Step Guide for Cloud Run
250+
251+
1. **Configure Permissions**: [Grant](https://cloud.google.com/run/docs/securing/managing-access#service-add-principals) the `roles/run.invoker` IAM role on the Cloud
252+
Run service to the principal. This could be your `user account email` or a
253+
`service account`.
254+
2. **Configure Credentials**
255+
- Local Development: Set up
256+
[ADC](https://cloud.google.com/docs/authentication/set-up-adc-local-dev-environment).
257+
- Google Cloud Environments: When running within Google Cloud (e.g., Compute
258+
Engine, GKE, another Cloud Run service, Cloud Functions), ADC is typically
259+
configured automatically, using the environment's default service account.
260+
3. **Connect to the Toolbox Server**
261+
262+
```python
263+
from toolbox_core import auth_methods
264+
265+
auth_token_provider = auth_methods.aget_google_id_token # can also use sync method
266+
client = ToolboxClient(
267+
URL,
268+
client_headers={"Authorization": auth_token_provider},
269+
)
270+
tools = await client.load_toolset()
271+
272+
# Now, you can use the client as usual.
273+
```
274+
275+
189276
## Authenticating Tools
190277

191278
> [!WARNING]

packages/toolbox-langchain/src/toolbox_langchain/async_client.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import Any, Callable, Optional, Union
15+
from typing import Any, Awaitable, Callable, Mapping, Optional, Union
1616
from warnings import warn
1717

1818
from aiohttp import ClientSession
@@ -30,6 +30,9 @@ def __init__(
3030
self,
3131
url: str,
3232
session: ClientSession,
33+
client_headers: Optional[
34+
Mapping[str, Union[Callable[[], str], Callable[[], Awaitable[str]], str]]
35+
] = None,
3336
):
3437
"""
3538
Initializes the AsyncToolboxClient for the Toolbox service at the given URL.
@@ -38,7 +41,9 @@ def __init__(
3841
url: The base URL of the Toolbox service.
3942
session: An HTTP client session.
4043
"""
41-
self.__core_client = ToolboxCoreClient(url=url, session=session)
44+
self.__core_client = ToolboxCoreClient(
45+
url=url, session=session, client_headers=client_headers
46+
)
4247

4348
async def aload_tool(
4449
self,
@@ -185,3 +190,18 @@ def load_toolset(
185190
strict: bool = False,
186191
) -> list[AsyncToolboxTool]:
187192
raise NotImplementedError("Synchronous methods not supported by async client.")
193+
194+
def add_headers(
195+
self,
196+
headers: Mapping[str, Union[Callable[[], str], Callable[[], Awaitable[str]]]],
197+
) -> None:
198+
"""
199+
Add headers to be included in each request sent through this client.
200+
201+
Args:
202+
headers: Headers to include in each request sent through this client.
203+
204+
Raises:
205+
ValueError: If any of the headers are already registered in the client.
206+
"""
207+
self.__core_client.add_headers(headers)

packages/toolbox-langchain/src/toolbox_langchain/client.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414

1515
from asyncio import to_thread
16-
from typing import Any, Callable, Optional, Union
16+
from typing import Any, Awaitable, Callable, Mapping, Optional, Union
1717
from warnings import warn
1818

1919
from toolbox_core.sync_client import ToolboxSyncClient as ToolboxCoreSyncClient
@@ -26,14 +26,19 @@ class ToolboxClient:
2626
def __init__(
2727
self,
2828
url: str,
29+
client_headers: Optional[
30+
Mapping[str, Union[Callable[[], str], Callable[[], Awaitable[str]], str]]
31+
] = None,
2932
) -> None:
3033
"""
3134
Initializes the ToolboxClient for the Toolbox service at the given URL.
3235
3336
Args:
3437
url: The base URL of the Toolbox service.
3538
"""
36-
self.__core_client = ToolboxCoreSyncClient(url=url)
39+
self.__core_client = ToolboxCoreSyncClient(
40+
url=url, client_headers=client_headers
41+
)
3742

3843
async def aload_tool(
3944
self,
@@ -286,3 +291,18 @@ def load_toolset(
286291
for core_sync_tool in core_sync_tools:
287292
tools.append(ToolboxTool(core_tool=core_sync_tool))
288293
return tools
294+
295+
def add_headers(
296+
self,
297+
headers: Mapping[str, Union[Callable[[], str], Callable[[], Awaitable[str]]]],
298+
) -> None:
299+
"""
300+
Add headers to be included in each request sent through this client.
301+
302+
Args:
303+
headers: Headers to include in each request sent through this client.
304+
305+
Raises:
306+
ValueError: If any of the headers are already registered in the client.
307+
"""
308+
self.__core_client.add_headers(headers)

packages/toolbox-langchain/tests/test_async_client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,22 @@ async def test_load_toolset_not_implemented(self, mock_client):
339339
assert "Synchronous methods not supported by async client." in str(
340340
excinfo.value
341341
)
342+
343+
@patch("toolbox_langchain.async_client.ToolboxCoreClient")
344+
async def test_init_with_client_headers(
345+
self, mock_core_client_constructor, mock_session
346+
):
347+
"""Tests that client_headers are passed to the core client during initialization."""
348+
headers = {"X-Test-Header": "value"}
349+
AsyncToolboxClient(URL, session=mock_session, client_headers=headers)
350+
mock_core_client_constructor.assert_called_once_with(
351+
url=URL, session=mock_session, client_headers=headers
352+
)
353+
354+
async def test_add_headers(self, mock_client):
355+
"""Tests that add_headers calls the core client's add_headers."""
356+
headers = {"X-Another-Header": lambda: "dynamic_value"}
357+
mock_client.add_headers(headers)
358+
mock_client._AsyncToolboxClient__core_client.add_headers.assert_called_once_with(
359+
headers
360+
)

packages/toolbox-langchain/tests/test_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,21 @@ async def test_aload_toolset_with_args(
420420
bound_params=bound_params,
421421
strict=True,
422422
)
423+
424+
@patch("toolbox_langchain.client.ToolboxCoreSyncClient")
425+
def test_init_with_client_headers(self, mock_core_client_constructor):
426+
"""Tests that client_headers are passed to the core client during initialization."""
427+
headers = {"X-Test-Header": "value"}
428+
ToolboxClient(URL, client_headers=headers)
429+
mock_core_client_constructor.assert_called_once_with(
430+
url=URL, client_headers=headers
431+
)
432+
433+
@patch("toolbox_langchain.client.ToolboxCoreSyncClient")
434+
def test_add_headers(self, mock_core_client_constructor):
435+
"""Tests that add_headers calls the core client's add_headers."""
436+
mock_core_instance = mock_core_client_constructor.return_value
437+
client = ToolboxClient(URL)
438+
headers = {"X-Another-Header": "dynamic_value"}
439+
client.add_headers(headers)
440+
mock_core_instance.add_headers.assert_called_once_with(headers)

0 commit comments

Comments
 (0)