sandboxy 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sandboxy/__init__.py +3 -0
- sandboxy/agents/__init__.py +21 -0
- sandboxy/agents/base.py +66 -0
- sandboxy/agents/llm_prompt.py +308 -0
- sandboxy/agents/loader.py +222 -0
- sandboxy/api/__init__.py +5 -0
- sandboxy/api/app.py +76 -0
- sandboxy/api/routes/__init__.py +1 -0
- sandboxy/api/routes/agents.py +92 -0
- sandboxy/api/routes/local.py +1388 -0
- sandboxy/api/routes/tools.py +106 -0
- sandboxy/cli/__init__.py +1 -0
- sandboxy/cli/main.py +1196 -0
- sandboxy/cli/type_detector.py +48 -0
- sandboxy/config.py +49 -0
- sandboxy/core/__init__.py +1 -0
- sandboxy/core/async_runner.py +824 -0
- sandboxy/core/mdl_parser.py +441 -0
- sandboxy/core/runner.py +599 -0
- sandboxy/core/safe_eval.py +165 -0
- sandboxy/core/state.py +234 -0
- sandboxy/datasets/__init__.py +20 -0
- sandboxy/datasets/loader.py +193 -0
- sandboxy/datasets/runner.py +442 -0
- sandboxy/errors.py +166 -0
- sandboxy/local/context.py +235 -0
- sandboxy/local/results.py +173 -0
- sandboxy/logging.py +31 -0
- sandboxy/mcp/__init__.py +25 -0
- sandboxy/mcp/client.py +360 -0
- sandboxy/mcp/wrapper.py +99 -0
- sandboxy/providers/__init__.py +34 -0
- sandboxy/providers/anthropic_provider.py +271 -0
- sandboxy/providers/base.py +123 -0
- sandboxy/providers/http_client.py +101 -0
- sandboxy/providers/openai_provider.py +282 -0
- sandboxy/providers/openrouter.py +958 -0
- sandboxy/providers/registry.py +199 -0
- sandboxy/scenarios/__init__.py +11 -0
- sandboxy/scenarios/comparison.py +491 -0
- sandboxy/scenarios/loader.py +262 -0
- sandboxy/scenarios/runner.py +468 -0
- sandboxy/scenarios/unified.py +1434 -0
- sandboxy/session/__init__.py +21 -0
- sandboxy/session/manager.py +278 -0
- sandboxy/tools/__init__.py +34 -0
- sandboxy/tools/base.py +127 -0
- sandboxy/tools/loader.py +270 -0
- sandboxy/tools/yaml_tools.py +708 -0
- sandboxy/ui/__init__.py +27 -0
- sandboxy/ui/dist/assets/index-CgAkYWrJ.css +1 -0
- sandboxy/ui/dist/assets/index-D4zoGFcr.js +347 -0
- sandboxy/ui/dist/index.html +14 -0
- sandboxy/utils/__init__.py +3 -0
- sandboxy/utils/time.py +20 -0
- sandboxy-0.0.1.dist-info/METADATA +241 -0
- sandboxy-0.0.1.dist-info/RECORD +60 -0
- sandboxy-0.0.1.dist-info/WHEEL +4 -0
- sandboxy-0.0.1.dist-info/entry_points.txt +3 -0
- sandboxy-0.0.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Direct Anthropic provider."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sandboxy.providers.base import BaseProvider, ModelInfo, ModelResponse, ProviderError
|
|
9
|
+
|
|
10
|
+
ANTHROPIC_MODELS = {
|
|
11
|
+
# Claude 4.5 Series (latest)
|
|
12
|
+
"claude-opus-4-5-20251101": ModelInfo(
|
|
13
|
+
id="claude-opus-4-5-20251101",
|
|
14
|
+
name="Claude Opus 4.5",
|
|
15
|
+
provider="anthropic",
|
|
16
|
+
context_length=200000,
|
|
17
|
+
input_cost_per_million=15.00,
|
|
18
|
+
output_cost_per_million=75.00,
|
|
19
|
+
supports_vision=True,
|
|
20
|
+
),
|
|
21
|
+
"claude-haiku-4-5-20251101": ModelInfo(
|
|
22
|
+
id="claude-haiku-4-5-20251101",
|
|
23
|
+
name="Claude Haiku 4.5",
|
|
24
|
+
provider="anthropic",
|
|
25
|
+
context_length=200000,
|
|
26
|
+
input_cost_per_million=0.80,
|
|
27
|
+
output_cost_per_million=4.00,
|
|
28
|
+
supports_vision=True,
|
|
29
|
+
),
|
|
30
|
+
# Claude 4 Series
|
|
31
|
+
"claude-sonnet-4-20250514": ModelInfo(
|
|
32
|
+
id="claude-sonnet-4-20250514",
|
|
33
|
+
name="Claude Sonnet 4",
|
|
34
|
+
provider="anthropic",
|
|
35
|
+
context_length=200000,
|
|
36
|
+
input_cost_per_million=3.00,
|
|
37
|
+
output_cost_per_million=15.00,
|
|
38
|
+
supports_vision=True,
|
|
39
|
+
),
|
|
40
|
+
"claude-opus-4-20250514": ModelInfo(
|
|
41
|
+
id="claude-opus-4-20250514",
|
|
42
|
+
name="Claude Opus 4",
|
|
43
|
+
provider="anthropic",
|
|
44
|
+
context_length=200000,
|
|
45
|
+
input_cost_per_million=15.00,
|
|
46
|
+
output_cost_per_million=75.00,
|
|
47
|
+
supports_vision=True,
|
|
48
|
+
),
|
|
49
|
+
# Claude 3.5 Series
|
|
50
|
+
"claude-3-5-sonnet-20241022": ModelInfo(
|
|
51
|
+
id="claude-3-5-sonnet-20241022",
|
|
52
|
+
name="Claude 3.5 Sonnet",
|
|
53
|
+
provider="anthropic",
|
|
54
|
+
context_length=200000,
|
|
55
|
+
input_cost_per_million=3.00,
|
|
56
|
+
output_cost_per_million=15.00,
|
|
57
|
+
supports_vision=True,
|
|
58
|
+
),
|
|
59
|
+
"claude-3-5-haiku-20241022": ModelInfo(
|
|
60
|
+
id="claude-3-5-haiku-20241022",
|
|
61
|
+
name="Claude 3.5 Haiku",
|
|
62
|
+
provider="anthropic",
|
|
63
|
+
context_length=200000,
|
|
64
|
+
input_cost_per_million=0.80,
|
|
65
|
+
output_cost_per_million=4.00,
|
|
66
|
+
supports_vision=True,
|
|
67
|
+
),
|
|
68
|
+
# Claude 3 Series (legacy)
|
|
69
|
+
"claude-3-opus-20240229": ModelInfo(
|
|
70
|
+
id="claude-3-opus-20240229",
|
|
71
|
+
name="Claude 3 Opus",
|
|
72
|
+
provider="anthropic",
|
|
73
|
+
context_length=200000,
|
|
74
|
+
input_cost_per_million=15.00,
|
|
75
|
+
output_cost_per_million=75.00,
|
|
76
|
+
supports_vision=True,
|
|
77
|
+
),
|
|
78
|
+
"claude-3-haiku-20240307": ModelInfo(
|
|
79
|
+
id="claude-3-haiku-20240307",
|
|
80
|
+
name="Claude 3 Haiku",
|
|
81
|
+
provider="anthropic",
|
|
82
|
+
context_length=200000,
|
|
83
|
+
input_cost_per_million=0.25,
|
|
84
|
+
output_cost_per_million=1.25,
|
|
85
|
+
supports_vision=True,
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Aliases for common model names
|
|
90
|
+
MODEL_ALIASES = {
|
|
91
|
+
# Claude 4.5
|
|
92
|
+
"claude-opus-4.5": "claude-opus-4-5-20251101",
|
|
93
|
+
"claude-opus-4-5": "claude-opus-4-5-20251101",
|
|
94
|
+
"claude-haiku-4.5": "claude-haiku-4-5-20251101",
|
|
95
|
+
"claude-haiku-4-5": "claude-haiku-4-5-20251101",
|
|
96
|
+
# Claude 4
|
|
97
|
+
"claude-sonnet-4": "claude-sonnet-4-20250514",
|
|
98
|
+
"claude-opus-4": "claude-opus-4-20250514",
|
|
99
|
+
# Claude 3.5
|
|
100
|
+
"claude-3.5-sonnet": "claude-3-5-sonnet-20241022",
|
|
101
|
+
"claude-3-5-sonnet": "claude-3-5-sonnet-20241022",
|
|
102
|
+
"claude-3.5-haiku": "claude-3-5-haiku-20241022",
|
|
103
|
+
"claude-3-5-haiku": "claude-3-5-haiku-20241022",
|
|
104
|
+
# Claude 3
|
|
105
|
+
"claude-3-opus": "claude-3-opus-20240229",
|
|
106
|
+
"claude-3-haiku": "claude-3-haiku-20240307",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AnthropicProvider(BaseProvider):
|
|
111
|
+
"""Direct Anthropic API provider.
|
|
112
|
+
|
|
113
|
+
Use this when you have an Anthropic API key and want to call
|
|
114
|
+
Claude models directly.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
provider_name = "anthropic"
|
|
118
|
+
|
|
119
|
+
def __init__(self, api_key: str | None = None):
|
|
120
|
+
"""Initialize Anthropic provider.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
api_key: Anthropic API key. If not provided, reads from
|
|
124
|
+
ANTHROPIC_API_KEY environment variable.
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
|
|
128
|
+
if not self.api_key:
|
|
129
|
+
raise ProviderError(
|
|
130
|
+
"API key required. Set ANTHROPIC_API_KEY or pass api_key.",
|
|
131
|
+
provider=self.provider_name,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Lazy import to avoid requiring anthropic package if not used
|
|
135
|
+
try:
|
|
136
|
+
from anthropic import AsyncAnthropic
|
|
137
|
+
|
|
138
|
+
self.client = AsyncAnthropic(api_key=self.api_key)
|
|
139
|
+
except ImportError as e:
|
|
140
|
+
raise ProviderError(
|
|
141
|
+
"anthropic package required. Install with: pip install anthropic",
|
|
142
|
+
provider=self.provider_name,
|
|
143
|
+
) from e
|
|
144
|
+
|
|
145
|
+
def _resolve_model(self, model: str) -> str:
|
|
146
|
+
"""Resolve model alias to full model ID."""
|
|
147
|
+
return MODEL_ALIASES.get(model, model)
|
|
148
|
+
|
|
149
|
+
async def complete(
|
|
150
|
+
self,
|
|
151
|
+
model: str,
|
|
152
|
+
messages: list[dict[str, Any]],
|
|
153
|
+
temperature: float = 0.7,
|
|
154
|
+
max_tokens: int = 1024,
|
|
155
|
+
**kwargs: Any,
|
|
156
|
+
) -> ModelResponse:
|
|
157
|
+
"""Send completion request to Anthropic."""
|
|
158
|
+
start_time = time.time()
|
|
159
|
+
resolved_model = self._resolve_model(model)
|
|
160
|
+
|
|
161
|
+
# Convert from OpenAI format to Anthropic format
|
|
162
|
+
system_prompt = None
|
|
163
|
+
anthropic_messages = []
|
|
164
|
+
|
|
165
|
+
for msg in messages:
|
|
166
|
+
if msg["role"] == "system":
|
|
167
|
+
system_prompt = msg["content"]
|
|
168
|
+
else:
|
|
169
|
+
anthropic_messages.append(
|
|
170
|
+
{
|
|
171
|
+
"role": msg["role"],
|
|
172
|
+
"content": msg["content"],
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
response = await self.client.messages.create(
|
|
178
|
+
model=resolved_model,
|
|
179
|
+
messages=anthropic_messages,
|
|
180
|
+
system=system_prompt or "",
|
|
181
|
+
temperature=temperature,
|
|
182
|
+
max_tokens=max_tokens,
|
|
183
|
+
**kwargs,
|
|
184
|
+
)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
raise ProviderError(
|
|
187
|
+
str(e),
|
|
188
|
+
provider=self.provider_name,
|
|
189
|
+
model=model,
|
|
190
|
+
) from e
|
|
191
|
+
|
|
192
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
193
|
+
|
|
194
|
+
# Extract content from response
|
|
195
|
+
content = ""
|
|
196
|
+
for block in response.content:
|
|
197
|
+
if block.type == "text":
|
|
198
|
+
content += block.text
|
|
199
|
+
|
|
200
|
+
input_tokens = response.usage.input_tokens
|
|
201
|
+
output_tokens = response.usage.output_tokens
|
|
202
|
+
cost = self._calculate_cost(resolved_model, input_tokens, output_tokens)
|
|
203
|
+
|
|
204
|
+
return ModelResponse(
|
|
205
|
+
content=content,
|
|
206
|
+
model_id=response.model,
|
|
207
|
+
latency_ms=latency_ms,
|
|
208
|
+
input_tokens=input_tokens,
|
|
209
|
+
output_tokens=output_tokens,
|
|
210
|
+
cost_usd=cost,
|
|
211
|
+
finish_reason=response.stop_reason,
|
|
212
|
+
raw_response=response.model_dump(),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def stream(
|
|
216
|
+
self,
|
|
217
|
+
model: str,
|
|
218
|
+
messages: list[dict[str, Any]],
|
|
219
|
+
temperature: float = 0.7,
|
|
220
|
+
max_tokens: int = 1024,
|
|
221
|
+
**kwargs: Any,
|
|
222
|
+
) -> AsyncIterator[str]:
|
|
223
|
+
"""Stream completion response from Anthropic."""
|
|
224
|
+
resolved_model = self._resolve_model(model)
|
|
225
|
+
|
|
226
|
+
# Convert from OpenAI format to Anthropic format
|
|
227
|
+
system_prompt = None
|
|
228
|
+
anthropic_messages = []
|
|
229
|
+
|
|
230
|
+
for msg in messages:
|
|
231
|
+
if msg["role"] == "system":
|
|
232
|
+
system_prompt = msg["content"]
|
|
233
|
+
else:
|
|
234
|
+
anthropic_messages.append(
|
|
235
|
+
{
|
|
236
|
+
"role": msg["role"],
|
|
237
|
+
"content": msg["content"],
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
async with self.client.messages.stream(
|
|
243
|
+
model=resolved_model,
|
|
244
|
+
messages=anthropic_messages,
|
|
245
|
+
system=system_prompt or "",
|
|
246
|
+
temperature=temperature,
|
|
247
|
+
max_tokens=max_tokens,
|
|
248
|
+
**kwargs,
|
|
249
|
+
) as stream:
|
|
250
|
+
async for text in stream.text_stream:
|
|
251
|
+
yield text
|
|
252
|
+
except Exception as e:
|
|
253
|
+
raise ProviderError(
|
|
254
|
+
str(e),
|
|
255
|
+
provider=self.provider_name,
|
|
256
|
+
model=model,
|
|
257
|
+
) from e
|
|
258
|
+
|
|
259
|
+
def list_models(self) -> list[ModelInfo]:
|
|
260
|
+
"""List available Anthropic models."""
|
|
261
|
+
return list(ANTHROPIC_MODELS.values())
|
|
262
|
+
|
|
263
|
+
def _calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float | None:
|
|
264
|
+
"""Calculate cost in USD for a request."""
|
|
265
|
+
model_info = ANTHROPIC_MODELS.get(model)
|
|
266
|
+
if not model_info or not model_info.input_cost_per_million:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
input_cost = (input_tokens / 1_000_000) * model_info.input_cost_per_million
|
|
270
|
+
output_cost = (output_tokens / 1_000_000) * (model_info.output_cost_per_million or 0)
|
|
271
|
+
return round(input_cost + output_cost, 6)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Base provider interface and common types."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProviderError(Exception):
|
|
10
|
+
"""Error from an LLM provider."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, provider: str, model: str | None = None):
|
|
13
|
+
"""Initialize provider error.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message: Error description
|
|
17
|
+
provider: Provider name that raised the error
|
|
18
|
+
model: Model ID if applicable
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
self.provider = provider
|
|
22
|
+
self.model = model
|
|
23
|
+
super().__init__(f"[{provider}] {message}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ModelResponse:
|
|
28
|
+
"""Response from a model completion."""
|
|
29
|
+
|
|
30
|
+
content: str
|
|
31
|
+
model_id: str
|
|
32
|
+
latency_ms: int
|
|
33
|
+
input_tokens: int
|
|
34
|
+
output_tokens: int
|
|
35
|
+
cost_usd: float | None = None
|
|
36
|
+
finish_reason: str | None = None
|
|
37
|
+
raw_response: dict[str, Any] | None = field(default=None, repr=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ModelInfo:
|
|
42
|
+
"""Information about an available model."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
name: str
|
|
46
|
+
provider: str
|
|
47
|
+
context_length: int
|
|
48
|
+
input_cost_per_million: float | None = None
|
|
49
|
+
output_cost_per_million: float | None = None
|
|
50
|
+
supports_tools: bool = True
|
|
51
|
+
supports_vision: bool = False
|
|
52
|
+
supports_streaming: bool = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BaseProvider(ABC):
|
|
56
|
+
"""Abstract base class for LLM providers."""
|
|
57
|
+
|
|
58
|
+
provider_name: str = "base"
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def complete(
|
|
62
|
+
self,
|
|
63
|
+
model: str,
|
|
64
|
+
messages: list[dict[str, Any]],
|
|
65
|
+
temperature: float = 0.7,
|
|
66
|
+
max_tokens: int = 1024,
|
|
67
|
+
**kwargs: Any,
|
|
68
|
+
) -> ModelResponse:
|
|
69
|
+
"""Send a completion request to the model.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
model: Model identifier (e.g., "gpt-4o", "claude-3-opus")
|
|
73
|
+
messages: List of message dicts with 'role' and 'content'
|
|
74
|
+
temperature: Sampling temperature (0-2)
|
|
75
|
+
max_tokens: Maximum tokens in response
|
|
76
|
+
**kwargs: Provider-specific options
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
ModelResponse with content and metadata
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ProviderError: If the request fails
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
async def stream(
|
|
88
|
+
self,
|
|
89
|
+
model: str,
|
|
90
|
+
messages: list[dict[str, Any]],
|
|
91
|
+
temperature: float = 0.7,
|
|
92
|
+
max_tokens: int = 1024,
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> AsyncIterator[str]:
|
|
95
|
+
"""Stream a completion response.
|
|
96
|
+
|
|
97
|
+
Default implementation falls back to non-streaming.
|
|
98
|
+
Override in subclasses for true streaming support.
|
|
99
|
+
"""
|
|
100
|
+
response = await self.complete(model, messages, temperature, max_tokens, **kwargs)
|
|
101
|
+
yield response.content
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def list_models(self) -> list[ModelInfo]:
|
|
105
|
+
"""List available models from this provider.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of ModelInfo objects
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def supports_model(self, model_id: str) -> bool:
|
|
114
|
+
"""Check if this provider supports a given model.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
model_id: Model identifier to check
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if the model is supported
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
return any(m.id == model_id for m in self.list_models())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Shared HTTP client with connection pooling.
|
|
2
|
+
|
|
3
|
+
Provides a singleton httpx.AsyncClient that reuses connections across requests,
|
|
4
|
+
significantly improving performance for repeated API calls.
|
|
5
|
+
|
|
6
|
+
Configuration via environment:
|
|
7
|
+
SANDBOXY_HTTP_TIMEOUT: Request timeout in seconds (default: 120)
|
|
8
|
+
SANDBOXY_HTTP_CONNECT_TIMEOUT: Connection timeout in seconds (default: 10)
|
|
9
|
+
SANDBOXY_HTTP_POOL_CONNECTIONS: Max keepalive connections (default: 20)
|
|
10
|
+
SANDBOXY_HTTP_POOL_MAXSIZE: Max total connections (default: 100)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Global client instance
|
|
23
|
+
_client: httpx.AsyncClient | None = None
|
|
24
|
+
|
|
25
|
+
# Configuration from environment
|
|
26
|
+
_TIMEOUT = float(os.environ.get("SANDBOXY_HTTP_TIMEOUT", "120"))
|
|
27
|
+
_CONNECT_TIMEOUT = float(os.environ.get("SANDBOXY_HTTP_CONNECT_TIMEOUT", "10"))
|
|
28
|
+
_POOL_CONNECTIONS = int(os.environ.get("SANDBOXY_HTTP_POOL_CONNECTIONS", "20"))
|
|
29
|
+
_POOL_MAXSIZE = int(os.environ.get("SANDBOXY_HTTP_POOL_MAXSIZE", "100"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _create_client() -> httpx.AsyncClient:
|
|
33
|
+
"""Create a new HTTP client with connection pooling."""
|
|
34
|
+
return httpx.AsyncClient(
|
|
35
|
+
timeout=httpx.Timeout(
|
|
36
|
+
_TIMEOUT,
|
|
37
|
+
connect=_CONNECT_TIMEOUT,
|
|
38
|
+
),
|
|
39
|
+
limits=httpx.Limits(
|
|
40
|
+
max_keepalive_connections=_POOL_CONNECTIONS,
|
|
41
|
+
max_connections=_POOL_MAXSIZE,
|
|
42
|
+
keepalive_expiry=30.0, # Keep idle connections for 30 seconds
|
|
43
|
+
),
|
|
44
|
+
# HTTP/2 disabled - requires h2 package (pip install httpx[http2])
|
|
45
|
+
# HTTP/1.1 works fine for API calls
|
|
46
|
+
http2=False,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_http_client() -> httpx.AsyncClient:
|
|
51
|
+
"""Get the shared HTTP client with connection pooling.
|
|
52
|
+
|
|
53
|
+
The client is created lazily on first access and reused for all subsequent
|
|
54
|
+
requests. This provides significant performance benefits for repeated API calls.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Shared httpx.AsyncClient instance.
|
|
58
|
+
|
|
59
|
+
Usage:
|
|
60
|
+
client = get_http_client()
|
|
61
|
+
response = await client.post(url, json=data)
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
global _client
|
|
65
|
+
if _client is None:
|
|
66
|
+
_client = _create_client()
|
|
67
|
+
logger.debug(
|
|
68
|
+
f"HTTP client created: pool_connections={_POOL_CONNECTIONS}, "
|
|
69
|
+
f"pool_maxsize={_POOL_MAXSIZE}, timeout={_TIMEOUT}s"
|
|
70
|
+
)
|
|
71
|
+
return _client
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def close_http_client() -> None:
|
|
75
|
+
"""Close the shared HTTP client.
|
|
76
|
+
|
|
77
|
+
Should be called during application shutdown to cleanly close connections.
|
|
78
|
+
"""
|
|
79
|
+
global _client
|
|
80
|
+
if _client is not None:
|
|
81
|
+
await _client.aclose()
|
|
82
|
+
_client = None
|
|
83
|
+
logger.debug("HTTP client closed")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def http_client_lifespan() -> AsyncIterator[httpx.AsyncClient]:
|
|
88
|
+
"""Context manager for HTTP client lifecycle.
|
|
89
|
+
|
|
90
|
+
Use this in application lifespan to ensure proper cleanup:
|
|
91
|
+
|
|
92
|
+
async with http_client_lifespan() as client:
|
|
93
|
+
# Application runs here
|
|
94
|
+
pass
|
|
95
|
+
# Client is automatically closed on exit
|
|
96
|
+
"""
|
|
97
|
+
client = get_http_client()
|
|
98
|
+
try:
|
|
99
|
+
yield client
|
|
100
|
+
finally:
|
|
101
|
+
await close_http_client()
|