Skip to content

Commit 5cd7fc4

Browse files
committed
Patched transport layer for server client
1 parent 77ae915 commit 5cd7fc4

4 files changed

Lines changed: 293 additions & 27 deletions

File tree

src/pydantic_ai_lightspeed/llamastack/_provider.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66

77
import httpx
88
from llama_stack.core.library_client import AsyncLlamaStackAsLibraryClient
9+
from llama_stack.core.request_headers import parse_request_provider_data
910
from llama_stack_client import AsyncLlamaStackClient
1011
from openai import AsyncOpenAI
1112
from pydantic_ai import ModelProfile
1213
from pydantic_ai.models import create_async_http_client
1314
from pydantic_ai.profiles.openai import openai_model_profile
1415
from pydantic_ai.providers import Provider
1516

16-
from pydantic_ai_lightspeed.llamastack._transport import LlamaStackLibraryTransport
17+
from pydantic_ai_lightspeed.llamastack._transport import (
18+
LlamaStackLibraryTransport,
19+
wrap_http_client_with_provider_data,
20+
)
1721

1822
if TYPE_CHECKING:
1923
from llama_stack.core.library_client import ( # pylint: disable=reimported
@@ -73,10 +77,13 @@ def from_llama_stack_client(
7377
api_key = client.api_key or "not-needed"
7478
base = str(client.base_url).rstrip("/")
7579
base_url = base if base.endswith("/v1") else f"{base}/v1"
80+
provider_data = parse_request_provider_data(client.default_headers)
81+
http_client = client._client # pylint: disable=protected-access
82+
http_client = wrap_http_client_with_provider_data(http_client, provider_data)
7683
return LlamaStackProvider(
7784
base_url=base_url,
7885
api_key=api_key,
79-
http_client=client._client, # pylint: disable=protected-access
86+
http_client=http_client,
8087
)
8188

8289
def __init__(
@@ -128,15 +135,11 @@ def __init__(
128135
base_url = base_url or DEFAULT_BASE_URL
129136
api_key = api_key or "not-needed"
130137

131-
if http_client is not None:
132-
self._client = AsyncOpenAI(
133-
base_url=base_url, api_key=api_key, http_client=http_client
134-
)
135-
else:
136-
oai_http_client = create_async_http_client()
137-
self._client = AsyncOpenAI(
138-
base_url=base_url, api_key=api_key, http_client=oai_http_client
139-
)
138+
self._client = AsyncOpenAI(
139+
base_url=base_url,
140+
api_key=api_key,
141+
http_client=http_client or create_async_http_client(),
142+
)
140143

141144
def __repr__(self) -> str:
142145
"""Return a string representation of the provider."""

src/pydantic_ai_lightspeed/llamastack/_transport.py

Lines changed: 141 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
"""httpx transport that routes OpenAI-compatible requests through a Llama Stack library client."""
1+
"""httpx transports for Llama Stack library and server modes."""
22

33
from __future__ import annotations as _annotations
44

55
import json
6-
from collections.abc import AsyncGenerator, AsyncIterator
6+
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
77
from typing import Any
88

99
import httpx
@@ -19,6 +19,141 @@
1919
from llama_stack.core.utils.context import preserve_contexts_async_generator
2020
from starlette.responses import StreamingResponse
2121

22+
_PROVIDER_DATA_HEADER_KEYS = (
23+
"X-LlamaStack-Provider-Data",
24+
"x-llamastack-provider-data",
25+
)
26+
27+
28+
def decode_request_headers(request: httpx.Request) -> dict[str, str]:
29+
"""Decode raw httpx request headers into a string-to-string mapping.
30+
31+
Args:
32+
request: The outgoing httpx request.
33+
34+
Returns:
35+
Request headers with byte keys and values decoded to UTF-8 strings.
36+
"""
37+
return {
38+
k.decode("utf-8") if isinstance(k, bytes) else k: (
39+
v.decode("utf-8") if isinstance(v, bytes) else v
40+
)
41+
for k, v in request.headers.raw
42+
}
43+
44+
45+
def inject_provider_data_into_headers(
46+
headers: Mapping[str, str],
47+
provider_data: Mapping[str, Any] | None,
48+
) -> dict[str, str]:
49+
"""Add ``X-LlamaStack-Provider-Data`` when provider data is configured.
50+
51+
Args:
52+
headers: Existing request headers.
53+
provider_data: Provider credentials/metadata to forward to Llama Stack.
54+
55+
Returns:
56+
Headers with provider data injected when absent from the request.
57+
"""
58+
if not provider_data:
59+
return dict(headers)
60+
if any(key in headers for key in _PROVIDER_DATA_HEADER_KEYS):
61+
return dict(headers)
62+
result = dict(headers)
63+
result["X-LlamaStack-Provider-Data"] = json.dumps(provider_data)
64+
return result
65+
66+
67+
def request_with_provider_data_headers(
68+
request: httpx.Request,
69+
provider_data: Mapping[str, Any] | None,
70+
) -> httpx.Request:
71+
"""Return a request copy with provider data headers injected when needed.
72+
73+
Args:
74+
request: The outgoing httpx request.
75+
provider_data: Provider credentials/metadata to forward to Llama Stack.
76+
77+
Returns:
78+
The original request, or a copy with provider data headers added.
79+
"""
80+
headers = inject_provider_data_into_headers(
81+
decode_request_headers(request),
82+
provider_data,
83+
)
84+
if headers == decode_request_headers(request):
85+
return request
86+
return httpx.Request(
87+
method=request.method,
88+
url=request.url,
89+
headers=headers,
90+
content=request.content,
91+
extensions=request.extensions,
92+
)
93+
94+
95+
def wrap_http_client_with_provider_data(
96+
http_client: httpx.AsyncClient,
97+
provider_data: Mapping[str, Any] | None,
98+
) -> httpx.AsyncClient:
99+
"""Wrap an httpx client so outbound requests include provider data headers.
100+
101+
Args:
102+
http_client: The client whose transport will be wrapped.
103+
provider_data: Provider credentials/metadata to forward to Llama Stack.
104+
105+
Returns:
106+
The original client when ``provider_data`` is empty, otherwise a new
107+
client with a wrapping transport.
108+
"""
109+
if not provider_data:
110+
return http_client
111+
112+
transport = LlamaStackServerTransport(
113+
http_client._transport, # pylint: disable=protected-access
114+
provider_data=provider_data,
115+
)
116+
return httpx.AsyncClient(
117+
transport=transport,
118+
timeout=http_client.timeout,
119+
follow_redirects=http_client.follow_redirects,
120+
)
121+
122+
123+
class LlamaStackServerTransport(httpx.AsyncBaseTransport):
124+
"""httpx transport that injects provider data headers before delegating over HTTP."""
125+
126+
def __init__(
127+
self,
128+
transport: httpx.AsyncBaseTransport,
129+
*,
130+
provider_data: Mapping[str, Any] | None = None,
131+
) -> None:
132+
"""Initialize the wrapping transport.
133+
134+
Args:
135+
transport: The underlying transport used for real HTTP requests.
136+
provider_data: Provider credentials/metadata to forward to Llama Stack.
137+
"""
138+
self._transport = transport
139+
self._provider_data = provider_data
140+
141+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
142+
"""Inject provider data headers and delegate to the wrapped transport.
143+
144+
Args:
145+
request: The outgoing httpx request.
146+
147+
Returns:
148+
The response from the wrapped transport.
149+
"""
150+
request = request_with_provider_data_headers(request, self._provider_data)
151+
return await self._transport.handle_async_request(request)
152+
153+
async def aclose(self) -> None:
154+
"""Close the wrapped transport."""
155+
await self._transport.aclose()
156+
22157

23158
class _AsyncByteStream(httpx.AsyncByteStream):
24159
"""Wraps an async byte generator as an httpx AsyncByteStream."""
@@ -80,19 +215,10 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
80215

81216
body = json.loads(request.content) if request.content else {}
82217

83-
headers: dict[str, str] = {
84-
k.decode("utf-8") if isinstance(k, bytes) else k: (
85-
v.decode("utf-8") if isinstance(v, bytes) else v
86-
)
87-
for k, v in request.headers.raw
88-
}
89-
90-
if self._client.provider_data:
91-
keys = ["X-LlamaStack-Provider-Data", "x-llamastack-provider-data"]
92-
if all(key not in headers for key in keys):
93-
headers["X-LlamaStack-Provider-Data"] = json.dumps(
94-
self._client.provider_data
95-
)
218+
headers = inject_provider_data_into_headers(
219+
decode_request_headers(request),
220+
self._client.provider_data,
221+
)
96222

97223
with request_provider_data_context(headers):
98224
is_stream = body.get("stream", False)

tests/unit/pydantic_ai_lightspeed/llamastack/test_provider.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
DEFAULT_BASE_URL,
1414
LlamaStackProvider,
1515
)
16+
from pydantic_ai_lightspeed.llamastack._transport import LlamaStackServerTransport
1617

1718

1819
class TestLlamaStackProviderProperties:
@@ -168,6 +169,7 @@ def test_server_client_extracts_base_url_with_v1(
168169
mock_client.base_url = "http://my-server:8321/v1"
169170
mock_client.api_key = "test-key"
170171
mock_client._client = mocker.Mock(spec=httpx.AsyncClient)
172+
mock_client.default_headers = {}
171173

172174
provider = LlamaStackProvider.from_llama_stack_client(mock_client)
173175

@@ -180,6 +182,7 @@ def test_server_client_appends_v1_when_missing(self, mocker: MockerFixture) -> N
180182
mock_client.base_url = "http://my-server:8321"
181183
mock_client.api_key = "test-key"
182184
mock_client._client = mocker.Mock(spec=httpx.AsyncClient)
185+
mock_client.default_headers = {}
183186

184187
provider = LlamaStackProvider.from_llama_stack_client(mock_client)
185188

@@ -193,6 +196,7 @@ def test_server_client_strips_trailing_slash_before_appending_v1(
193196
mock_client.base_url = "http://my-server:8321/"
194197
mock_client.api_key = "test-key"
195198
mock_client._client = mocker.Mock(spec=httpx.AsyncClient)
199+
mock_client.default_headers = {}
196200

197201
provider = LlamaStackProvider.from_llama_stack_client(mock_client)
198202

@@ -205,6 +209,7 @@ def test_server_client_uses_provided_api_key(self, mocker: MockerFixture) -> Non
205209
mock_client.base_url = "http://my-server:8321/v1"
206210
mock_client.api_key = "my-secret"
207211
mock_client._client = mocker.Mock(spec=httpx.AsyncClient)
212+
mock_client.default_headers = {}
208213

209214
provider = LlamaStackProvider.from_llama_stack_client(mock_client)
210215

@@ -218,23 +223,45 @@ def test_server_client_defaults_api_key_when_none(
218223
mock_client.base_url = "http://my-server:8321/v1"
219224
mock_client.api_key = None
220225
mock_client._client = mocker.Mock(spec=httpx.AsyncClient)
226+
mock_client.default_headers = {}
221227

222228
provider = LlamaStackProvider.from_llama_stack_client(mock_client)
223229

224230
assert provider.client.api_key == "not-needed"
225231

226232
def test_server_client_passes_http_client(self, mocker: MockerFixture) -> None:
227-
"""Test that the server client's internal httpx client is reused."""
233+
"""Test that the server client's internal httpx client is reused when no provider data."""
228234
mock_client = mocker.Mock(spec=AsyncLlamaStackClient)
229235
mock_client.base_url = "http://my-server:8321/v1"
230236
mock_client.api_key = "test-key"
231237
inner_http = mocker.Mock(spec=httpx.AsyncClient)
232238
mock_client._client = inner_http
239+
mock_client.default_headers = {}
233240

234241
provider = LlamaStackProvider.from_llama_stack_client(mock_client)
235242

236243
assert provider._client._client is inner_http
237244

245+
def test_server_client_wraps_transport_with_provider_data(
246+
self, mocker: MockerFixture
247+
) -> None:
248+
"""Test provider data from default_headers is forwarded in server mode."""
249+
mock_client = mocker.Mock(spec=AsyncLlamaStackClient)
250+
mock_client.base_url = "http://my-server:8321/v1"
251+
mock_client.api_key = "test-key"
252+
inner_http = httpx.AsyncClient()
253+
mock_client._client = inner_http
254+
mock_client.default_headers = {
255+
"X-LlamaStack-Provider-Data": '{"azure_api_key": "token"}'
256+
}
257+
258+
provider = LlamaStackProvider.from_llama_stack_client(mock_client)
259+
260+
assert isinstance(
261+
provider._client._client._transport, # pylint: disable=protected-access
262+
LlamaStackServerTransport,
263+
)
264+
238265

239266
class TestSetHttpClient: # pylint: disable=too-few-public-methods
240267
"""Tests for LlamaStackProvider._set_http_client."""

0 commit comments

Comments
 (0)