Skip to content

Commit 88c514e

Browse files
committed
Add TLS env wiring for LLM HTTPS transport (CA bundle + mTLS)
1 parent c20c9c6 commit 88c514e

4 files changed

Lines changed: 127 additions & 4 deletions

File tree

docs/acceptance.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ track p50/p95 latency across eval suites and detect regressions.*
513513
| `test_ultrawork_notify.py` | Webhook delivery, shell command, failure silence |
514514
| `test_eval_report.py` | HTML rendering, case names, pass/fail, judge scores |
515515
| `test_benchmark.py` | p50/p95/mean latency, regression detection, serialisation |
516+
| `test_llm_transport.py` | TLS env wiring: CA bundle + client cert passed into LLM HTTPS transport |
516517

517518
---
518519

@@ -522,5 +523,4 @@ The following stories remain unimplemented.
522523

523524
| ID | Story | Priority |
524525
|---|---|---|
525-
| AC-NEW-16 | HTTPS_PROXY / CA bundle / mTLS "just works" | P2 |
526526
| AC-NEW-20 | Local model (Ollama/vLLM) with full governance | P2 |

teaagent/llm/_adapters.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
_first_choice_delta,
1414
)
1515
from teaagent.llm._retry import DEFAULT_RETRY_CONFIG, LLMRetryConfig, _call_with_retry
16-
from teaagent.llm._transport import UrllibHTTPTransport
16+
from teaagent.llm._transport import UrllibHTTPTransport, build_ssl_context_from_env
1717
from teaagent.llm._types import (
1818
HTTPTransport,
1919
LLMHTTPError,
@@ -205,7 +205,11 @@ def _complete_streaming(
205205
}
206206
req = urllib_request.Request(url, data=body, headers=headers, method='POST')
207207
try:
208-
with urllib_request.urlopen(req, timeout=self.timeout) as resp:
208+
ssl_context = build_ssl_context_from_env()
209+
request_kwargs: dict[str, Any] = {'timeout': self.timeout}
210+
if ssl_context is not None:
211+
request_kwargs['context'] = ssl_context
212+
with urllib_request.urlopen(req, **request_kwargs) as resp:
209213
lines = list(resp)
210214
except HTTPError as exc:
211215
detail = exc.read().decode('utf-8', errors='replace')

teaagent/llm/_transport.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
from __future__ import annotations
22

33
import json
4+
import os
5+
import ssl
46
from typing import Any
57
from urllib import request as urllib_request
68
from urllib.error import HTTPError, URLError
79

810
from teaagent.llm._types import LLMHTTPError
911

1012

13+
def build_ssl_context_from_env() -> ssl.SSLContext | None:
14+
ca_bundle = os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('SSL_CERT_FILE')
15+
client_cert = os.environ.get('TEAAGENT_TLS_CLIENT_CERT')
16+
client_key = os.environ.get('TEAAGENT_TLS_CLIENT_KEY')
17+
if not ca_bundle and not client_cert:
18+
return None
19+
context = ssl.create_default_context()
20+
if ca_bundle:
21+
context.load_verify_locations(cafile=ca_bundle)
22+
if client_cert:
23+
if client_key:
24+
context.load_cert_chain(certfile=client_cert, keyfile=client_key)
25+
else:
26+
context.load_cert_chain(certfile=client_cert)
27+
return context
28+
29+
1130
class UrllibHTTPTransport:
1231
def post_json(
1332
self,
@@ -29,7 +48,11 @@ def post_json(
2948
method='POST',
3049
)
3150
try:
32-
with urllib_request.urlopen(req, timeout=timeout) as response:
51+
ssl_context = build_ssl_context_from_env()
52+
request_kwargs: dict[str, Any] = {'timeout': timeout}
53+
if ssl_context is not None:
54+
request_kwargs['context'] = ssl_context
55+
with urllib_request.urlopen(req, **request_kwargs) as response:
3356
return json.loads(response.read().decode('utf-8'))
3457
except HTTPError as exc:
3558
detail = exc.read().decode('utf-8', errors='replace')

tests/test_llm_transport.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import unittest
6+
from unittest.mock import MagicMock, patch
7+
8+
from teaagent.llm._transport import UrllibHTTPTransport, build_ssl_context_from_env
9+
10+
11+
class _FakeHTTPResponse:
12+
def __init__(self, payload: dict[str, object]) -> None:
13+
self._payload = payload
14+
15+
def __enter__(self) -> '_FakeHTTPResponse':
16+
return self
17+
18+
def __exit__(self, exc_type, exc, tb) -> None:
19+
return None
20+
21+
def read(self) -> bytes:
22+
return json.dumps(self._payload).encode('utf-8')
23+
24+
25+
class LLMTransportTests(unittest.TestCase):
26+
def test_build_ssl_context_returns_none_without_tls_env(self) -> None:
27+
with patch.dict(os.environ, {}, clear=True):
28+
self.assertIsNone(build_ssl_context_from_env())
29+
30+
def test_build_ssl_context_loads_ca_bundle_from_requests_env(self) -> None:
31+
context = MagicMock()
32+
with (
33+
patch.dict(os.environ, {'REQUESTS_CA_BUNDLE': '/tmp/ca.pem'}, clear=True),
34+
patch(
35+
'teaagent.llm._transport.ssl.create_default_context',
36+
return_value=context,
37+
),
38+
):
39+
result = build_ssl_context_from_env()
40+
41+
self.assertIs(result, context)
42+
context.load_verify_locations.assert_called_once_with(cafile='/tmp/ca.pem')
43+
44+
def test_build_ssl_context_loads_client_cert_and_key(self) -> None:
45+
context = MagicMock()
46+
with (
47+
patch.dict(
48+
os.environ,
49+
{
50+
'TEAAGENT_TLS_CLIENT_CERT': '/tmp/client.crt',
51+
'TEAAGENT_TLS_CLIENT_KEY': '/tmp/client.key',
52+
},
53+
clear=True,
54+
),
55+
patch(
56+
'teaagent.llm._transport.ssl.create_default_context',
57+
return_value=context,
58+
),
59+
):
60+
result = build_ssl_context_from_env()
61+
62+
self.assertIs(result, context)
63+
context.load_cert_chain.assert_called_once_with(
64+
certfile='/tmp/client.crt', keyfile='/tmp/client.key'
65+
)
66+
67+
def test_transport_post_json_passes_ssl_context_when_tls_env_set(self) -> None:
68+
context = MagicMock()
69+
transport = UrllibHTTPTransport()
70+
71+
with (
72+
patch.dict(os.environ, {'REQUESTS_CA_BUNDLE': '/tmp/ca.pem'}, clear=True),
73+
patch(
74+
'teaagent.llm._transport.ssl.create_default_context',
75+
return_value=context,
76+
),
77+
patch(
78+
'teaagent.llm._transport.urllib_request.urlopen',
79+
return_value=_FakeHTTPResponse({'ok': True}),
80+
) as mock_urlopen,
81+
):
82+
result = transport.post_json(
83+
'https://example.com/v1',
84+
{'authorization': 'Bearer x'},
85+
{'hello': 'world'},
86+
timeout=5,
87+
)
88+
89+
self.assertEqual(result, {'ok': True})
90+
_, kwargs = mock_urlopen.call_args
91+
self.assertEqual(kwargs['timeout'], 5)
92+
self.assertIs(kwargs['context'], context)
93+
94+
95+
if __name__ == '__main__':
96+
unittest.main()

0 commit comments

Comments
 (0)