diff --git a/python/packages/typesafe/AGENTS.md b/python/packages/typesafe/AGENTS.md index 657a124e773..3b8f7a96c32 100644 --- a/python/packages/typesafe/AGENTS.md +++ b/python/packages/typesafe/AGENTS.md @@ -29,9 +29,9 @@ Integration with TypeSafe AI System One models, including Jev. `SystemOneResponse` as the response model. - An injected `AsyncTypeSafeClient` is caller-owned. A client created by `TypeSafeChatClient` is closed by `close()` or the async context manager. -- Connector-owned clients restore the configured `Bearer` API-key header at the HTTP transport boundary because - TypeSafe SDK 0.7.1 keeps the prepared authorization header redacted; transport-level tests must verify the outgoing - header. +- Connector-owned clients let the TypeSafe SDK send the `Bearer` API-key header and do not replace its HTTP transport, + so httpx2 environment proxy settings such as `HTTPS_PROXY` and `NO_PROXY` apply; transport-level tests must verify + the outgoing header. - Settings use `load_settings`; the API key is required only when the connector creates the SDK client. - Injected SDK clients remain authoritative: ambient model and endpoint settings are not applied to them. diff --git a/python/packages/typesafe/agent_framework_typesafe/_chat_client.py b/python/packages/typesafe/agent_framework_typesafe/_chat_client.py index a1c18782e2b..51237c00cdb 100644 --- a/python/packages/typesafe/agent_framework_typesafe/_chat_client.py +++ b/python/packages/typesafe/agent_framework_typesafe/_chat_client.py @@ -8,7 +8,6 @@ from typing import Any, ClassVar, Literal, NoReturn, cast, overload from uuid import uuid4 -import httpx2 from agent_framework import ( BaseChatClient, ChatAndFunctionMiddlewareTypes, @@ -60,23 +59,6 @@ _TYPESAFE_SYSTEM_ONE_PATH = "/v1/systemone" -class _ApiKeyTransport(httpx2.AsyncBaseTransport): - """Restore bearer authentication after the TypeSafe SDK prepares redacted wire headers.""" - - def __init__(self, api_key: str) -> None: - self._api_key = api_key - self._transport = httpx2.AsyncHTTPTransport() - - @override - async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: - request.headers["Authorization"] = f"Bearer {self._api_key}" - return await self._transport.handle_async_request(request) - - @override - async def aclose(self) -> None: - await self._transport.aclose() - - class TypeSafeSettings(TypedDict, total=False): """TypeSafe settings resolved from explicit values, .env files, or the environment.""" @@ -180,13 +162,11 @@ def __init__( self.base_url = settings.get("base_url") or _TYPESAFE_DEFAULT_BASE_URL self._service_url = f"{self.base_url.rstrip('/')}{_TYPESAFE_SYSTEM_ONE_PATH}" api_key_secret = cast(SecretString, settings.get("api_key")) - api_key_value = api_key_secret.get_secret_value() self.client = AsyncTypeSafeClient( - api_key=api_key_value, + api_key=api_key_secret.get_secret_value(), model=self.model, base_url=self.base_url, headers={"User-Agent": get_user_agent()}, - transport=_ApiKeyTransport(api_key_value), ) super().__init__( compaction_strategy=compaction_strategy, diff --git a/python/packages/typesafe/tests/typesafe/test_chat_client.py b/python/packages/typesafe/tests/typesafe/test_chat_client.py index 2595b45c10f..6aebd5c8e74 100644 --- a/python/packages/typesafe/tests/typesafe/test_chat_client.py +++ b/python/packages/typesafe/tests/typesafe/test_chat_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import contextlib from collections.abc import Callable from pathlib import Path @@ -262,9 +263,15 @@ def handle_request(request: httpx2.Request) -> httpx2.Response: json=make_response().model_dump(mode="json"), ) + def create_client(**kwargs: Any) -> AsyncTypeSafeClient: + # The connector must leave the SDK's HTTP stack alone so proxy settings still apply. + assert "transport" not in kwargs + assert "http_client" not in kwargs + return AsyncTypeSafeClient(**kwargs, transport=httpx2.MockTransport(handle_request)) + monkeypatch.setattr( - "agent_framework_typesafe._chat_client.httpx2.AsyncHTTPTransport", - lambda: httpx2.MockTransport(handle_request), + "agent_framework_typesafe._chat_client.AsyncTypeSafeClient", + create_client, ) async with TypeSafeChatClient( @@ -279,6 +286,49 @@ def handle_request(request: httpx2.Request) -> httpx2.Response: assert authorization_headers == ["Bearer configured-api-key"] +async def test_owned_client_honors_proxy_environment(monkeypatch: pytest.MonkeyPatch) -> None: + proxied_requests: list[tuple[str, str]] = [] + body = make_response().model_dump_json().encode() + + async def handle_proxy_request(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + request_line = (await reader.readline()).decode("latin-1").strip() + headers: dict[str, str] = {} + while (line := await reader.readline()) not in (b"\r\n", b""): + name, _, value = line.decode("latin-1").partition(":") + headers[name.strip().lower()] = value.strip() + await reader.readexactly(int(headers.get("content-length", "0"))) + proxied_requests.append((request_line, headers.get("authorization", ""))) + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nx-typesafe-request-id: request-123\r\n" + + f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode() + + body + ) + await writer.drain() + writer.close() + await writer.wait_closed() + + proxy = await asyncio.start_server(handle_proxy_request, "127.0.0.1", 0) + proxy_port = proxy.sockets[0].getsockname()[1] + for name in ("http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("HTTP_PROXY", f"http://127.0.0.1:{proxy_port}") + + async with ( + proxy, + TypeSafeChatClient( + api_key="configured-api-key", + base_url="http://typesafe.invalid", + ) as client, + ): + response = await client.get_response( + [Message("user", ["hello"])], + options={"response_format": questions()}, + ) + + assert proxied_requests == [("POST http://typesafe.invalid/v1/systemone HTTP/1.1", "Bearer configured-api-key")] + assert isinstance(response.value, SystemOneResponse) + + async def test_streaming_is_rejected_on_consumption() -> None: client = make_client()