Skip to content

Commit da30710

Browse files
authored
refactor: 重写http client传入模型的方式 (#88)
- 支持用户传入http client 的构建对象 - 默认的构建对象是临时的http client, 如果期望共享http client可以自行配置
1 parent 4f0cc06 commit da30710

12 files changed

Lines changed: 554 additions & 292 deletions

File tree

docs/mkdocs/en/model.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,27 @@ model = OpenAIModel(
121121
)
122122
```
123123

124+
#### Advanced Usage
125+
126+
Since version `1.1.10`, `OpenAIModel` supports passing a shared HTTP client provider to enable connection reuse. By default, `OpenAIModel` creates a temporary HTTP client for each model-service request. If you want to reuse connections, use the following configuration:
127+
128+
```python
129+
from trpc_agent_sdk.models import OpenAIModel
130+
from trpc_agent_sdk.models import shared_http_client_provider_factory
131+
# from trpc_agent_sdk.models import close_shared_http_clients
132+
133+
model = OpenAIModel(
134+
model_name="deepseek-chat",
135+
api_key="your-api-key",
136+
base_url="https://api.deepseek.com/v1",
137+
http_client_provider_factory=shared_http_client_provider_factory,
138+
)
139+
# ...
140+
141+
# If you need to force-close the shared HTTP clients, use:
142+
# await close_shared_http_clients()
143+
```
144+
124145
### Integration with Various Platform Model Services:
125146

126147
#### Hunyuan Model Invocation
@@ -204,6 +225,10 @@ LlmAgent(
204225
)
205226
```
206227

228+
#### Advanced Usage
229+
230+
Since version `1.1.10`, `AnthropicModel` supports passing a shared HTTP client provider to enable connection reuse. By default, `AnthropicModel` creates a temporary HTTP client for each model-service request. See the Advanced Usage section under `OpenAIModel` for an example.
231+
207232
## LiteLLMModel
208233
As multiple LLM providers have emerged, some have defined their own API specifications. Currently, the framework has integrated OpenAI and Anthropic APIs as described above. However, differences in instantiation methods and configuration options across providers mean that developers often need to modify substantial amounts of code when switching providers, increasing the switching cost.
209234
To address this issue, tRPC-Agent supports unified multi-provider model access through [LiteLLM](https://docs.litellm.ai/), using the **provider/model** format (e.g., `openai/gpt-4o`, `anthropic/claude-3-5-sonnet`, `gemini/gemini-1.5-pro`), enabling switching between different backends with a single invocation pattern. LiteLLMModel inherits from OpenAIModel and only overrides the API call path to `litellm.acompletion`, simplifying the complexity of provider switching.

docs/mkdocs/zh/model.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,27 @@ model = OpenAIModel(
121121
)
122122
```
123123

124+
#### 高级用法
125+
126+
从版本 `1.1.10`之后 OpenAIModel 支持传入共享的 http client 来解决连接复用的场景,当前的 OpenAIModel 默认每次都会创建临时的 http client 去访问模型服务;如果期望连接复用可以使用如下的方式
127+
128+
```python
129+
from trpc_agent_sdk.models import OpenAIModel
130+
from trpc_agent_sdk.models import shared_http_client_provider_factory
131+
#from trpc_agent_sdk.models import close_shared_http_clients
132+
133+
model = OpenAIModel(
134+
model_name="deepseek-chat",
135+
api_key="your-api-key",
136+
base_url="https://api.deepseek.com/v1",
137+
http_client_provider_factory=shared_http_client_provider_factory
138+
)
139+
# ...
140+
141+
# 如果需要强制关闭共享的 http client 可以采用如下方式
142+
# await close_shared_http_clients()
143+
```
144+
124145
### 各个平台模型服务的对接方式:
125146

126147
#### hunyuan模型调用方式
@@ -204,6 +225,10 @@ LlmAgent(
204225
)
205226
```
206227

228+
#### 高级用法
229+
230+
从版本 `1.1.10`之后 AnthropicModel 支持传入共享的 http client 来解决连接复用的场景,当前的 OpenAIModel 默认每次都会创建临时的 http client 去访问模型服务;参考:OpenAIModel 章节中的高级用法
231+
207232
## LiteLLMModel
208233
随着多个大模型供应商的出现,一些供应商定义了各自的 API 规范。目前,框架已接入 OpenAI 和 Anthropic 的 API(如上文所述),然而,不同供应商在实例化方式和配置项上存在差异,开发者在切换供应商时往往需要修改大量代码,增加了切换成本。
209234
为了解决这一问题,tRPC-Agent 支持通过 [LiteLLM](https://docs.litellm.ai/) 统一接入多厂商模型,使用 **provider/model** 格式(如 `openai/gpt-4o``anthropic/claude-3-5-sonnet``gemini/gemini-1.5-pro`),一套调用方式切换不同后端。LiteLLMModel 继承 OpenAIModel,仅覆盖 API 调用路径为 `litellm.acompletion`,从而简化了供应商切换的复杂度。

tests/models/test_anthropic_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -710,13 +710,13 @@ async def test_generate_single_error_raises_and_closes_client(self):
710710
model = AnthropicModel(model_name="claude-3-5-sonnet-20241022", api_key="test-key")
711711
client = MagicMock()
712712
client.messages.create = AsyncMock(side_effect=TimeoutError("timeout"))
713-
client.close = AsyncMock()
713+
model._http_client_provider.close_http_client = AsyncMock()
714714

715715
with patch.object(model, "_create_async_client", return_value=client):
716716
with pytest.raises(TimeoutError):
717717
await model._generate_single({}, LlmRequest(contents=[]))
718718

719-
client.close.assert_awaited_once()
719+
model._http_client_provider.close_http_client.assert_awaited_once_with(client)
720720

721721
@pytest.mark.asyncio
722722
async def test_generate_async_converts_provider_exception_to_retry_error_response(self):

tests/models/test_anthropic_model_ext.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,11 +472,11 @@ async def test_api_error_raises_and_closes_client(self):
472472
model = _model()
473473
mock_client = AsyncMock()
474474
mock_client.messages.create = AsyncMock(side_effect=RuntimeError("timeout"))
475-
mock_client.close = AsyncMock()
475+
model._http_client_provider.close_http_client = AsyncMock()
476476
with patch.object(model, "_create_async_client", return_value=mock_client):
477477
with pytest.raises(RuntimeError, match="timeout"):
478478
await model._generate_single({}, LlmRequest(contents=[]))
479-
mock_client.close.assert_awaited_once()
479+
model._http_client_provider.close_http_client.assert_awaited_once_with(mock_client)
480480

481481

482482
# ---------------------------------------------------------------------------

tests/models/test_openai_model.py

Lines changed: 120 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
from unittest.mock import Mock
99
from unittest.mock import patch
1010

11-
import httpx
12-
import openai
1311
import pytest
1412
from trpc_agent_sdk.models import LlmRequest
1513
from trpc_agent_sdk.models import OpenAIModel
@@ -242,23 +240,26 @@ def test_model_type_is_model(self):
242240

243241
assert model._type == FilterType.MODEL
244242

245-
def test_create_async_client_uses_custom_http_client_factory(self):
246-
"""A custom http_client_factory is passed through to AsyncOpenAI."""
243+
def test_create_async_client_uses_custom_http_client_provider(self):
244+
"""A custom http_client_provider_factory is passed through to AsyncOpenAI."""
247245
shared_http_client = Mock()
248-
http_client_factory = Mock(return_value=shared_http_client)
246+
http_client_provider = Mock()
247+
http_client_provider.create_http_client.return_value = shared_http_client
248+
http_client_provider_factory = Mock(return_value=http_client_provider)
249249
model = OpenAIModel(
250250
model_name="gpt-4",
251251
api_key="test_key",
252252
base_url="https://custom.api.com",
253253
client_args={"timeout": 30},
254-
http_client_factory=http_client_factory,
254+
http_client_provider_factory=http_client_provider_factory,
255255
)
256256

257257
with patch("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI") as mock_async_openai:
258258
client = model._create_async_client()
259259

260260
assert client is mock_async_openai.return_value
261-
http_client_factory.assert_called_once_with()
261+
http_client_provider_factory.assert_called_once_with()
262+
http_client_provider.create_http_client.assert_called_once_with()
262263
mock_async_openai.assert_called_once_with(
263264
api_key="test_key",
264265
max_retries=0,
@@ -268,39 +269,130 @@ def test_create_async_client_uses_custom_http_client_factory(self):
268269
http_client=shared_http_client,
269270
)
270271

271-
def test_create_async_client_default_factory_reuses_shared_http_client(self):
272-
"""Default factory should reuse one shared httpx.AsyncClient across model calls."""
273-
from trpc_agent_sdk.models import _openai_model
272+
def test_create_async_client_default_factory_reuses_loop_local_http_client(self):
273+
"""Default provider should reuse the same httpx.AsyncClient within one loop."""
274+
from trpc_agent_sdk.models import _httpx_client
275+
from trpc_agent_sdk.models import shared_http_client_provider_factory
274276

275-
_openai_model._shared_http_client = None
276277
shared_http_client = Mock()
277-
model = OpenAIModel(model_name="gpt-4", api_key="test_key")
278+
shared_http_client.is_closed = False
279+
model = OpenAIModel(model_name="gpt-4", api_key="test_key", http_client_provider_factory=shared_http_client_provider_factory)
278280

279281
try:
280-
with patch("trpc_agent_sdk.models._openai_model.httpx.AsyncClient",
282+
_httpx_client._shared_http_clients.clear()
283+
with patch("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient",
281284
return_value=shared_http_client) as mock_httpx_client:
282-
with patch("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI") as mock_async_openai:
283-
model._create_async_client()
284-
model._create_async_client()
285+
with patch("trpc_agent_sdk.models._httpx_client._get_loop_key", return_value=1):
286+
with patch("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI") as mock_async_openai:
287+
model._create_async_client()
288+
model._create_async_client()
285289
finally:
286-
_openai_model._shared_http_client = None
290+
_httpx_client._shared_http_clients.clear()
287291

288-
mock_httpx_client.assert_called_once_with()
292+
mock_httpx_client.assert_called_once_with(
293+
limits=_httpx_client._DEFAULT_HTTP_CLIENT_LIMITS,
294+
timeout=_httpx_client._DEFAULT_HTTP_CLIENT_TIMEOUT,
295+
follow_redirects=True,
296+
)
289297
first_call_kwargs = mock_async_openai.call_args_list[0].kwargs
290298
second_call_kwargs = mock_async_openai.call_args_list[1].kwargs
291299
assert first_call_kwargs["http_client"] is shared_http_client
292300
assert second_call_kwargs["http_client"] is shared_http_client
293301

302+
def test_create_shared_http_client_rebuilds_closed_client(self):
303+
"""Closed cached clients should be replaced on the next factory call."""
304+
from trpc_agent_sdk.models import _httpx_client
305+
306+
closed_client = Mock()
307+
closed_client.is_closed = True
308+
fresh_client = Mock()
309+
fresh_client.is_closed = False
310+
311+
try:
312+
_httpx_client._shared_http_clients.clear()
313+
client_key = (1234, 1)
314+
_httpx_client._shared_http_clients[client_key] = closed_client
315+
with patch("trpc_agent_sdk.models._httpx_client._get_client_key", return_value=client_key):
316+
with patch("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient",
317+
return_value=fresh_client) as mock_httpx_client:
318+
assert _httpx_client._create_shared_http_client() is fresh_client
319+
finally:
320+
_httpx_client._shared_http_clients.clear()
321+
322+
mock_httpx_client.assert_called_once()
323+
324+
def test_create_shared_http_client_does_not_reuse_across_loop_keys(self):
325+
"""Different event loops should get different default httpx clients."""
326+
from trpc_agent_sdk.models import _httpx_client
327+
328+
first_client = Mock()
329+
first_client.is_closed = False
330+
second_client = Mock()
331+
second_client.is_closed = False
332+
333+
try:
334+
_httpx_client._shared_http_clients.clear()
335+
with patch("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient",
336+
side_effect=[first_client, second_client]) as mock_httpx_client:
337+
with patch("trpc_agent_sdk.models._httpx_client._get_client_key", side_effect=[(1234, 1), (1234, 2)]):
338+
assert _httpx_client._create_shared_http_client() is first_client
339+
assert _httpx_client._create_shared_http_client() is second_client
340+
finally:
341+
_httpx_client._shared_http_clients.clear()
342+
343+
assert mock_httpx_client.call_count == 2
344+
345+
def test_create_shared_http_client_does_not_reuse_across_process_keys(self):
346+
"""Different process keys should get different default httpx clients."""
347+
from trpc_agent_sdk.models import _httpx_client
348+
349+
parent_client = Mock()
350+
parent_client.is_closed = False
351+
child_client = Mock()
352+
child_client.is_closed = False
353+
354+
try:
355+
_httpx_client._shared_http_clients.clear()
356+
with patch("trpc_agent_sdk.models._httpx_client.httpx.AsyncClient",
357+
side_effect=[parent_client, child_client]) as mock_httpx_client:
358+
with patch("trpc_agent_sdk.models._httpx_client._get_client_key",
359+
side_effect=[(1234, 1), (5678, 1)]):
360+
assert _httpx_client._create_shared_http_client() is parent_client
361+
assert _httpx_client._create_shared_http_client() is child_client
362+
finally:
363+
_httpx_client._shared_http_clients.clear()
364+
365+
assert mock_httpx_client.call_count == 2
366+
367+
def test_reset_shared_http_clients_after_fork_clears_cache_and_rebuilds_lock(self):
368+
"""Fork child reset should drop inherited clients and replace inherited locks."""
369+
from trpc_agent_sdk.models import _httpx_client
370+
371+
inherited_client = Mock()
372+
old_lock = _httpx_client._shared_http_clients_lock
373+
374+
try:
375+
_httpx_client._shared_http_clients[(1234, 1)] = inherited_client
376+
377+
_httpx_client._reset_shared_http_clients_after_fork()
378+
379+
assert _httpx_client._shared_http_clients == {}
380+
assert _httpx_client._shared_http_clients_lock is not old_lock
381+
finally:
382+
_httpx_client._shared_http_clients.clear()
383+
294384
def test_create_async_client_overwrites_stale_client_args_http_client(self):
295-
"""Factory owns http_client injection even if client_args already has one."""
385+
"""Provider owns http_client injection even if client_args already has one."""
296386
stale_http_client = Mock()
297387
fresh_http_client = Mock()
298-
http_client_factory = Mock(return_value=fresh_http_client)
388+
http_client_provider = Mock()
389+
http_client_provider.create_http_client.return_value = fresh_http_client
390+
http_client_provider_factory = Mock(return_value=http_client_provider)
299391
model = OpenAIModel(
300392
model_name="gpt-4",
301393
api_key="test_key",
302394
client_args={"http_client": stale_http_client, "timeout": 30},
303-
http_client_factory=http_client_factory,
395+
http_client_provider_factory=http_client_provider_factory,
304396
)
305397

306398
with patch("trpc_agent_sdk.models._openai_model.openai.AsyncOpenAI") as mock_async_openai:
@@ -354,15 +446,19 @@ async def test_generate_async_simple_text_response(self):
354446

355447
@pytest.mark.asyncio
356448
async def test_generate_async_validation_failure(self):
357-
"""Test generate_async converts validation failures to error responses."""
449+
"""Test generate_async returns an error response on invalid request."""
358450
model = OpenAIModel(model_name="gpt-4", api_key="test_key")
359451

452+
# Empty contents
360453
request = LlmRequest(contents=[], config=None, tools_dict={})
361454

362-
responses = [response async for response in model.generate_async(request, stream=False)]
455+
responses = []
456+
async for response in model.generate_async(request, stream=False):
457+
responses.append(response)
458+
363459
assert len(responses) == 1
364460
assert responses[0].error_code == "API_ERROR"
365-
assert "At least one content is required" in (responses[0].error_message or "")
461+
assert "At least one content is required" in responses[0].error_message
366462

367463
@pytest.mark.asyncio
368464
async def test_generate_async_with_config_parameters(self):
@@ -849,47 +945,3 @@ def test_null_prompt_tokens_details_does_not_crash(self):
849945
}
850946
meta = OpenAIModel._build_usage_metadata(usage_data)
851947
assert meta.cache_read_input_tokens is None
852-
853-
854-
class _RetryTestError(Exception):
855-
856-
def __init__(self, status_code=None, headers=None):
857-
super().__init__(f"status {status_code}" if status_code is not None else "retry test")
858-
if status_code is not None:
859-
self.status_code = status_code
860-
if headers is not None:
861-
self.response = type("Resp", (), {"headers": headers})()
862-
863-
864-
class TestOpenAIModelRetryHooks:
865-
866-
def _model(self):
867-
return OpenAIModel(model_name="gpt-4", api_key="test_key")
868-
869-
def test_x_should_retry_header_has_priority(self):
870-
model = self._model()
871-
assert model._get_model_retry_info(_RetryTestError(400, {"x-should-retry": "true"})).should_retry is True
872-
assert model._get_model_retry_info(_RetryTestError(500, {"x-should-retry": "false"})).should_retry is False
873-
874-
@pytest.mark.parametrize("status_code", [408, 409, 429, 500, 503])
875-
def test_retryable_status_codes(self, status_code):
876-
assert self._model()._get_model_retry_info(_RetryTestError(status_code)).should_retry is True
877-
878-
@pytest.mark.parametrize("status_code", [400, 401, 403, 404, 499])
879-
def test_non_retryable_status_codes(self, status_code):
880-
assert self._model()._get_model_retry_info(_RetryTestError(status_code)).should_retry is False
881-
882-
def test_timeout_exception_retried(self):
883-
request = httpx.Request("GET", "https://example.com")
884-
assert self._model()._get_model_retry_info(httpx.TimeoutException("timeout", request=request)).should_retry is True
885-
886-
def test_openai_error_not_retried_without_status_decision(self):
887-
assert self._model()._get_model_retry_info(openai.OpenAIError("boom")).should_retry is False
888-
889-
def test_other_exception_retried(self):
890-
assert self._model()._get_model_retry_info(ValueError("boom")).should_retry is True
891-
892-
def test_retry_after_extracted_from_headers(self):
893-
info = self._model()._get_model_retry_info(_RetryTestError(429, {"retry-after": "3"}))
894-
assert info.should_retry is True
895-
assert info.retry_after == 3.0

trpc_agent_sdk/agents/_base_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,8 @@ async def run_async(
257257
- Actions
258258
"""
259259
from trpc_agent_sdk.telemetry import report_invoke_agent
260-
from trpc_agent_sdk.telemetry._trace import tracer
261-
from trpc_agent_sdk.telemetry._trace import trace_agent
260+
from trpc_agent_sdk.telemetry import tracer
261+
from trpc_agent_sdk.telemetry import trace_agent
262262

263263
# Manually propagate span context using attach/detach instead of
264264
# start_as_current_span. This ensures child spans (call_llm, execute_tool,

trpc_agent_sdk/models/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
from ._openai_model import OpenAIModel
4646
from ._openai_model import ToolCall
4747
from ._openai_model import ToolKey
48+
from ._httpx_client import close_shared_http_clients
49+
from ._httpx_client import temporary_http_client_provider_factory
50+
from ._httpx_client import shared_http_client_provider_factory
51+
from ._httpx_client import HttpClientProviderFactory
4852
from ._registry import ModelRegistry
4953
from ._registry import register_model
5054

@@ -85,6 +89,10 @@
8589
"OpenAIModel",
8690
"ToolCall",
8791
"ToolKey",
92+
"close_shared_http_clients",
93+
"temporary_http_client_provider_factory",
94+
"shared_http_client_provider_factory",
95+
"HttpClientProviderFactory",
8896
"ModelRegistry",
8997
"register_model",
9098
]

0 commit comments

Comments
 (0)