Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions python/packages/typesafe/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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,
Expand Down
54 changes: 52 additions & 2 deletions python/packages/typesafe/tests/typesafe/test_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import contextlib
from collections.abc import Callable
from pathlib import Path
Expand Down Expand Up @@ -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(
Expand All @@ -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()

Expand Down
Loading