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.
Files changed (60) hide show
  1. sandboxy/__init__.py +3 -0
  2. sandboxy/agents/__init__.py +21 -0
  3. sandboxy/agents/base.py +66 -0
  4. sandboxy/agents/llm_prompt.py +308 -0
  5. sandboxy/agents/loader.py +222 -0
  6. sandboxy/api/__init__.py +5 -0
  7. sandboxy/api/app.py +76 -0
  8. sandboxy/api/routes/__init__.py +1 -0
  9. sandboxy/api/routes/agents.py +92 -0
  10. sandboxy/api/routes/local.py +1388 -0
  11. sandboxy/api/routes/tools.py +106 -0
  12. sandboxy/cli/__init__.py +1 -0
  13. sandboxy/cli/main.py +1196 -0
  14. sandboxy/cli/type_detector.py +48 -0
  15. sandboxy/config.py +49 -0
  16. sandboxy/core/__init__.py +1 -0
  17. sandboxy/core/async_runner.py +824 -0
  18. sandboxy/core/mdl_parser.py +441 -0
  19. sandboxy/core/runner.py +599 -0
  20. sandboxy/core/safe_eval.py +165 -0
  21. sandboxy/core/state.py +234 -0
  22. sandboxy/datasets/__init__.py +20 -0
  23. sandboxy/datasets/loader.py +193 -0
  24. sandboxy/datasets/runner.py +442 -0
  25. sandboxy/errors.py +166 -0
  26. sandboxy/local/context.py +235 -0
  27. sandboxy/local/results.py +173 -0
  28. sandboxy/logging.py +31 -0
  29. sandboxy/mcp/__init__.py +25 -0
  30. sandboxy/mcp/client.py +360 -0
  31. sandboxy/mcp/wrapper.py +99 -0
  32. sandboxy/providers/__init__.py +34 -0
  33. sandboxy/providers/anthropic_provider.py +271 -0
  34. sandboxy/providers/base.py +123 -0
  35. sandboxy/providers/http_client.py +101 -0
  36. sandboxy/providers/openai_provider.py +282 -0
  37. sandboxy/providers/openrouter.py +958 -0
  38. sandboxy/providers/registry.py +199 -0
  39. sandboxy/scenarios/__init__.py +11 -0
  40. sandboxy/scenarios/comparison.py +491 -0
  41. sandboxy/scenarios/loader.py +262 -0
  42. sandboxy/scenarios/runner.py +468 -0
  43. sandboxy/scenarios/unified.py +1434 -0
  44. sandboxy/session/__init__.py +21 -0
  45. sandboxy/session/manager.py +278 -0
  46. sandboxy/tools/__init__.py +34 -0
  47. sandboxy/tools/base.py +127 -0
  48. sandboxy/tools/loader.py +270 -0
  49. sandboxy/tools/yaml_tools.py +708 -0
  50. sandboxy/ui/__init__.py +27 -0
  51. sandboxy/ui/dist/assets/index-CgAkYWrJ.css +1 -0
  52. sandboxy/ui/dist/assets/index-D4zoGFcr.js +347 -0
  53. sandboxy/ui/dist/index.html +14 -0
  54. sandboxy/utils/__init__.py +3 -0
  55. sandboxy/utils/time.py +20 -0
  56. sandboxy-0.0.1.dist-info/METADATA +241 -0
  57. sandboxy-0.0.1.dist-info/RECORD +60 -0
  58. sandboxy-0.0.1.dist-info/WHEEL +4 -0
  59. sandboxy-0.0.1.dist-info/entry_points.txt +3 -0
  60. 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()