stirrup 0.1.2__py3-none-any.whl → 0.1.4__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.
stirrup/__init__.py CHANGED
@@ -35,6 +35,7 @@ from stirrup.core.models import (
35
35
  AssistantMessage,
36
36
  AudioContentBlock,
37
37
  ChatMessage,
38
+ EmptyParams,
38
39
  ImageContentBlock,
39
40
  LLMClient,
40
41
  SubAgentMetadata,
@@ -58,6 +59,7 @@ __all__ = [
58
59
  "AudioContentBlock",
59
60
  "ChatMessage",
60
61
  "ContextOverflowError",
62
+ "EmptyParams",
61
63
  "ImageContentBlock",
62
64
  "LLMClient",
63
65
  "SubAgentMetadata",
@@ -3,12 +3,17 @@
3
3
  The default client is ChatCompletionsClient, which uses the OpenAI SDK directly
4
4
  and supports any OpenAI-compatible API via the `base_url` parameter.
5
5
 
6
+ OpenResponsesClient uses the OpenAI Responses API (responses.create) for providers
7
+ that support this newer API format.
8
+
6
9
  For multi-provider support via LiteLLM, install the litellm extra:
7
10
  pip install stirrup[litellm]
8
11
  """
9
12
 
10
13
  from stirrup.clients.chat_completions_client import ChatCompletionsClient
14
+ from stirrup.clients.open_responses_client import OpenResponsesClient
11
15
 
12
16
  __all__ = [
13
17
  "ChatCompletionsClient",
18
+ "OpenResponsesClient",
14
19
  ]
@@ -67,7 +67,6 @@ class ChatCompletionsClient(LLMClient):
67
67
  *,
68
68
  base_url: str | None = None,
69
69
  api_key: str | None = None,
70
- supports_audio_input: bool = False,
71
70
  reasoning_effort: str | None = None,
72
71
  timeout: float | None = None,
73
72
  max_retries: int = 2,
@@ -82,7 +81,6 @@ class ChatCompletionsClient(LLMClient):
82
81
  Use for OpenAI-compatible providers (e.g., 'http://localhost:8000/v1').
83
82
  api_key: API key for authentication. If None, reads from OPENROUTER_API_KEY
84
83
  environment variable.
85
- supports_audio_input: Whether the model supports audio inputs. Defaults to False.
86
84
  reasoning_effort: Reasoning effort level for extended thinking models
87
85
  (e.g., 'low', 'medium', 'high'). Only used with o1/o3 style models.
88
86
  timeout: Request timeout in seconds. If None, uses OpenAI SDK default.
@@ -92,7 +90,6 @@ class ChatCompletionsClient(LLMClient):
92
90
  """
93
91
  self._model = model
94
92
  self._max_tokens = max_tokens
95
- self._supports_audio_input = supports_audio_input
96
93
  self._reasoning_effort = reasoning_effort
97
94
  self._kwargs = kwargs or {}
98
95
 
@@ -7,7 +7,7 @@ Requires the litellm extra: `pip install stirrup[litellm]`
7
7
  """
8
8
 
9
9
  import logging
10
- from typing import Any
10
+ from typing import Any, Literal
11
11
 
12
12
  try:
13
13
  from litellm import acompletion
@@ -38,6 +38,8 @@ __all__ = [
38
38
 
39
39
  LOGGER = logging.getLogger(__name__)
40
40
 
41
+ type ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "default"]
42
+
41
43
 
42
44
  class LiteLLMClient(LLMClient):
43
45
  """LiteLLM-based client supporting multiple LLM providers with unified interface.
@@ -49,8 +51,8 @@ class LiteLLMClient(LLMClient):
49
51
  self,
50
52
  model_slug: str,
51
53
  max_tokens: int,
52
- supports_audio_input: bool = False,
53
- reasoning_effort: str | None = None,
54
+ api_key: str | None = None,
55
+ reasoning_effort: ReasoningEffort | None = None,
54
56
  kwargs: dict[str, Any] | None = None,
55
57
  ) -> None:
56
58
  """Initialize LiteLLM client with model configuration and capabilities.
@@ -58,15 +60,13 @@ class LiteLLMClient(LLMClient):
58
60
  Args:
59
61
  model_slug: Model identifier for LiteLLM (e.g., 'anthropic/claude-3-5-sonnet-20241022')
60
62
  max_tokens: Maximum context window size in tokens
61
- supports_audio_input: Whether the model supports audio inputs
62
63
  reasoning_effort: Reasoning effort level for extended thinking models (e.g., 'medium', 'high')
63
64
  kwargs: Additional arguments to pass to LiteLLM completion calls
64
65
  """
65
66
  self._model_slug = model_slug
66
- self._supports_video_input = False
67
- self._supports_audio_input = supports_audio_input
68
67
  self._max_tokens = max_tokens
69
- self._reasoning_effort = reasoning_effort
68
+ self._reasoning_effort: ReasoningEffort | None = reasoning_effort
69
+ self._api_key = api_key
70
70
  self._kwargs = kwargs or {}
71
71
 
72
72
  @property
@@ -92,6 +92,8 @@ class LiteLLMClient(LLMClient):
92
92
  tools=to_openai_tools(tools) if tools else None,
93
93
  tool_choice="auto" if tools else None,
94
94
  max_tokens=self._max_tokens,
95
+ reasoning_effort=self._reasoning_effort,
96
+ api_key=self._api_key,
95
97
  **self._kwargs,
96
98
  )
97
99
 
@@ -103,14 +105,20 @@ class LiteLLMClient(LLMClient):
103
105
  )
104
106
 
105
107
  msg = choice["message"]
106
-
107
108
  reasoning: Reasoning | None = None
108
109
  if getattr(msg, "reasoning_content", None) is not None:
109
110
  reasoning = Reasoning(content=msg.reasoning_content)
110
111
  if getattr(msg, "thinking_blocks", None) is not None and len(msg.thinking_blocks) > 0:
111
- reasoning = Reasoning(
112
- signature=msg.thinking_blocks[0]["signature"], content=msg.thinking_blocks[0]["content"]
113
- )
112
+ if len(msg.thinking_blocks) > 1:
113
+ raise ValueError("Found multiple thinking blocks in the response")
114
+
115
+ signature = msg.thinking_blocks[0].get("thinking_signature", None)
116
+ content = msg.thinking_blocks[0].get("thinking", None)
117
+
118
+ if signature is None and content is None:
119
+ raise ValueError("Signature and content not found in the thinking block response")
120
+
121
+ reasoning = Reasoning(signature=signature, content=content)
114
122
 
115
123
  usage = r["usage"]
116
124
 
@@ -119,6 +127,7 @@ class LiteLLMClient(LLMClient):
119
127
  tool_call_id=tc.get("id"),
120
128
  name=tc["function"]["name"],
121
129
  arguments=tc["function"].get("arguments", "") or "",
130
+ signature=tc.get("provider_specific_fields", {}).get("thought_signature", None),
122
131
  )
123
132
  for tc in (msg.get("tool_calls") or [])
124
133
  ]
@@ -0,0 +1,434 @@
1
+ """OpenAI SDK-based LLM client for the Responses API.
2
+
3
+ This client uses the official OpenAI Python SDK's responses.create() method,
4
+ supporting both OpenAI's API and any OpenAI-compatible endpoint that implements
5
+ the Responses API via the `base_url` parameter.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from typing import Any
11
+
12
+ from openai import (
13
+ APIConnectionError,
14
+ APITimeoutError,
15
+ AsyncOpenAI,
16
+ InternalServerError,
17
+ RateLimitError,
18
+ )
19
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
20
+
21
+ from stirrup.core.exceptions import ContextOverflowError
22
+ from stirrup.core.models import (
23
+ AssistantMessage,
24
+ AudioContentBlock,
25
+ ChatMessage,
26
+ Content,
27
+ EmptyParams,
28
+ ImageContentBlock,
29
+ LLMClient,
30
+ Reasoning,
31
+ SystemMessage,
32
+ TokenUsage,
33
+ Tool,
34
+ ToolCall,
35
+ ToolMessage,
36
+ UserMessage,
37
+ VideoContentBlock,
38
+ )
39
+
40
+ __all__ = [
41
+ "OpenResponsesClient",
42
+ ]
43
+
44
+ LOGGER = logging.getLogger(__name__)
45
+
46
+
47
+ def _content_to_open_responses_input(content: Content) -> list[dict[str, Any]]:
48
+ """Convert Content blocks to OpenResponses input content format.
49
+
50
+ Uses input_text for text content (vs output_text for responses).
51
+ """
52
+ if isinstance(content, str):
53
+ return [{"type": "input_text", "text": content}]
54
+
55
+ out: list[dict[str, Any]] = []
56
+ for block in content:
57
+ if isinstance(block, str):
58
+ out.append({"type": "input_text", "text": block})
59
+ elif isinstance(block, ImageContentBlock):
60
+ out.append({"type": "input_image", "image_url": block.to_base64_url()})
61
+ elif isinstance(block, AudioContentBlock):
62
+ out.append(
63
+ {
64
+ "type": "input_audio",
65
+ "input_audio": {
66
+ "data": block.to_base64_url().split(",")[1],
67
+ "format": block.extension,
68
+ },
69
+ }
70
+ )
71
+ elif isinstance(block, VideoContentBlock):
72
+ out.append({"type": "input_file", "file_data": block.to_base64_url()})
73
+ else:
74
+ raise NotImplementedError(f"Unsupported content block: {type(block)}")
75
+ return out
76
+
77
+
78
+ def _content_to_open_responses_output(content: Content) -> list[dict[str, Any]]:
79
+ """Convert Content blocks to OpenResponses output content format.
80
+
81
+ Uses output_text for assistant message content.
82
+ """
83
+ if isinstance(content, str):
84
+ return [{"type": "output_text", "text": content}]
85
+
86
+ out: list[dict[str, Any]] = []
87
+ for block in content:
88
+ if isinstance(block, str):
89
+ out.append({"type": "output_text", "text": block})
90
+ else:
91
+ raise NotImplementedError(f"Unsupported output content block: {type(block)}")
92
+ return out
93
+
94
+
95
+ def _to_open_responses_tools(tools: dict[str, Tool]) -> list[dict[str, Any]]:
96
+ """Convert Tool objects to OpenResponses function format.
97
+
98
+ OpenResponses API expects tools with name/description/parameters at top level,
99
+ not nested under a 'function' key like Chat Completions API.
100
+
101
+ Args:
102
+ tools: Dictionary mapping tool names to Tool objects.
103
+
104
+ Returns:
105
+ List of tool definitions in OpenResponses format.
106
+ """
107
+ out: list[dict[str, Any]] = []
108
+ for t in tools.values():
109
+ tool_def: dict[str, Any] = {
110
+ "type": "function",
111
+ "name": t.name,
112
+ "description": t.description,
113
+ }
114
+ if t.parameters is not EmptyParams:
115
+ tool_def["parameters"] = t.parameters.model_json_schema()
116
+ out.append(tool_def)
117
+ return out
118
+
119
+
120
+ def _to_open_responses_input(
121
+ msgs: list[ChatMessage],
122
+ ) -> tuple[str | None, list[dict[str, Any]]]:
123
+ """Convert ChatMessage list to OpenResponses (instructions, input) tuple.
124
+
125
+ SystemMessage content is extracted as the instructions parameter.
126
+ Other messages are converted to input items.
127
+
128
+ Returns:
129
+ Tuple of (instructions, input_items) where instructions is the system
130
+ message content (or None) and input_items is the list of input items.
131
+ """
132
+ instructions: str | None = None
133
+ input_items: list[dict[str, Any]] = []
134
+
135
+ for m in msgs:
136
+ if isinstance(m, SystemMessage):
137
+ # Extract system message as instructions
138
+ if isinstance(m.content, str):
139
+ instructions = m.content
140
+ else:
141
+ # Join text content blocks for instructions
142
+ instructions = "\n".join(block if isinstance(block, str) else "" for block in m.content)
143
+ elif isinstance(m, UserMessage):
144
+ input_items.append(
145
+ {
146
+ "role": "user",
147
+ "content": _content_to_open_responses_input(m.content),
148
+ }
149
+ )
150
+ elif isinstance(m, AssistantMessage):
151
+ # For assistant messages, we need to add them as response output items
152
+ # First add any text content as a message item
153
+ content_str = (
154
+ m.content
155
+ if isinstance(m.content, str)
156
+ else "\n".join(block if isinstance(block, str) else "" for block in m.content)
157
+ )
158
+ if content_str:
159
+ input_items.append(
160
+ {
161
+ "type": "message",
162
+ "role": "assistant",
163
+ "content": [{"type": "output_text", "text": content_str}],
164
+ }
165
+ )
166
+
167
+ # Add tool calls as separate function_call items
168
+ input_items.extend(
169
+ {
170
+ "type": "function_call",
171
+ "call_id": tc.tool_call_id,
172
+ "name": tc.name,
173
+ "arguments": tc.arguments,
174
+ }
175
+ for tc in m.tool_calls
176
+ )
177
+ elif isinstance(m, ToolMessage):
178
+ # Tool results are function_call_output items
179
+ content_str = m.content if isinstance(m.content, str) else str(m.content)
180
+ input_items.append(
181
+ {
182
+ "type": "function_call_output",
183
+ "call_id": m.tool_call_id,
184
+ "output": content_str,
185
+ }
186
+ )
187
+ else:
188
+ raise NotImplementedError(f"Unsupported message type: {type(m)}")
189
+
190
+ return instructions, input_items
191
+
192
+
193
+ def _get_attr(obj: Any, name: str, default: Any = None) -> Any: # noqa: ANN401
194
+ """Get attribute from object or dict, with fallback default."""
195
+ if isinstance(obj, dict):
196
+ return obj.get(name, default)
197
+ return getattr(obj, name, default)
198
+
199
+
200
+ def _parse_response_output(
201
+ output: list[Any],
202
+ ) -> tuple[str, list[ToolCall], Reasoning | None]:
203
+ """Parse response output items into content, tool_calls, and reasoning.
204
+
205
+ Args:
206
+ output: List of output items from the response.
207
+
208
+ Returns:
209
+ Tuple of (content_text, tool_calls, reasoning).
210
+ """
211
+ content_parts: list[str] = []
212
+ tool_calls: list[ToolCall] = []
213
+ reasoning: Reasoning | None = None
214
+
215
+ for item in output:
216
+ item_type = _get_attr(item, "type")
217
+
218
+ if item_type == "message":
219
+ # Extract text content from message
220
+ msg_content = _get_attr(item, "content", [])
221
+ for content_item in msg_content:
222
+ content_type = _get_attr(content_item, "type")
223
+ if content_type == "output_text":
224
+ text = _get_attr(content_item, "text", "")
225
+ content_parts.append(text)
226
+
227
+ elif item_type == "function_call":
228
+ call_id = _get_attr(item, "call_id")
229
+ name = _get_attr(item, "name")
230
+ arguments = _get_attr(item, "arguments", "")
231
+ tool_calls.append(
232
+ ToolCall(
233
+ tool_call_id=call_id,
234
+ name=name,
235
+ arguments=arguments,
236
+ )
237
+ )
238
+
239
+ elif item_type == "reasoning":
240
+ # Extract reasoning/thinking content - try multiple possible attribute names
241
+ # summary can be a list of Summary objects with .text attribute
242
+ summary = _get_attr(item, "summary")
243
+ if summary:
244
+ if isinstance(summary, list):
245
+ # Extract text from Summary objects
246
+ thinking = "\n".join(_get_attr(s, "text", "") for s in summary if _get_attr(s, "text"))
247
+ else:
248
+ thinking = str(summary)
249
+ else:
250
+ thinking = _get_attr(item, "thinking") or ""
251
+
252
+ if thinking:
253
+ reasoning = Reasoning(content=thinking)
254
+
255
+ return "\n".join(content_parts), tool_calls, reasoning
256
+
257
+
258
+ class OpenResponsesClient(LLMClient):
259
+ """OpenAI SDK-based client using the Responses API.
260
+
261
+ Uses the official OpenAI Python SDK's responses.create() method.
262
+ Supports custom base_url for OpenAI-compatible providers that implement
263
+ the Responses API.
264
+
265
+ Includes automatic retries for transient failures and token usage tracking.
266
+
267
+ Example:
268
+ >>> # Standard OpenAI usage
269
+ >>> client = OpenResponsesClient(model="gpt-4o", max_tokens=128_000)
270
+ >>>
271
+ >>> # Custom OpenAI-compatible endpoint
272
+ >>> client = OpenResponsesClient(
273
+ ... model="gpt-4o",
274
+ ... base_url="http://localhost:8000/v1",
275
+ ... api_key="your-api-key",
276
+ ... )
277
+ """
278
+
279
+ def __init__(
280
+ self,
281
+ model: str,
282
+ max_tokens: int = 64_000,
283
+ *,
284
+ base_url: str | None = None,
285
+ api_key: str | None = None,
286
+ reasoning_effort: str | None = None,
287
+ timeout: float | None = None,
288
+ max_retries: int = 2,
289
+ instructions: str | None = None,
290
+ kwargs: dict[str, Any] | None = None,
291
+ ) -> None:
292
+ """Initialize OpenAI SDK client with model configuration for Responses API.
293
+
294
+ Args:
295
+ model: Model identifier (e.g., 'gpt-4o', 'o1-preview').
296
+ max_tokens: Maximum output tokens. Defaults to 64,000.
297
+ base_url: API base URL. If None, uses OpenAI's standard URL.
298
+ Use for OpenAI-compatible providers.
299
+ api_key: API key for authentication. If None, reads from OPENROUTER_API_KEY
300
+ environment variable.
301
+ reasoning_effort: Reasoning effort level for extended thinking models
302
+ (e.g., 'low', 'medium', 'high'). Only used with o1/o3 style models.
303
+ timeout: Request timeout in seconds. If None, uses OpenAI SDK default.
304
+ max_retries: Number of retries for transient errors. Defaults to 2.
305
+ instructions: Default system-level instructions. Can be overridden by
306
+ SystemMessage in the messages list.
307
+ kwargs: Additional arguments passed to responses.create().
308
+ """
309
+ self._model = model
310
+ self._max_tokens = max_tokens
311
+ self._reasoning_effort = reasoning_effort
312
+ self._default_instructions = instructions
313
+ self._kwargs = kwargs or {}
314
+
315
+ # Initialize AsyncOpenAI client
316
+ resolved_api_key = api_key or os.environ.get("OPENAI_API_KEY")
317
+
318
+ # Strip /responses suffix if present - SDK appends it automatically
319
+ resolved_base_url = base_url
320
+ if resolved_base_url and resolved_base_url.rstrip("/").endswith("/responses"):
321
+ resolved_base_url = resolved_base_url.rstrip("/").removesuffix("/responses")
322
+
323
+ self._client = AsyncOpenAI(
324
+ api_key=resolved_api_key,
325
+ base_url=resolved_base_url,
326
+ timeout=timeout,
327
+ max_retries=max_retries,
328
+ )
329
+
330
+ @property
331
+ def max_tokens(self) -> int:
332
+ """Maximum output tokens."""
333
+ return self._max_tokens
334
+
335
+ @property
336
+ def model_slug(self) -> str:
337
+ """Model identifier."""
338
+ return self._model
339
+
340
+ @retry(
341
+ retry=retry_if_exception_type(
342
+ (
343
+ APIConnectionError,
344
+ APITimeoutError,
345
+ RateLimitError,
346
+ InternalServerError,
347
+ )
348
+ ),
349
+ stop=stop_after_attempt(3),
350
+ wait=wait_exponential(multiplier=1, min=1, max=10),
351
+ )
352
+ async def generate(
353
+ self,
354
+ messages: list[ChatMessage],
355
+ tools: dict[str, Tool],
356
+ ) -> AssistantMessage:
357
+ """Generate assistant response with optional tool calls using Responses API.
358
+
359
+ Retries up to 3 times on transient errors (connection, timeout, rate limit,
360
+ internal server errors) with exponential backoff.
361
+
362
+ Args:
363
+ messages: List of conversation messages.
364
+ tools: Dictionary mapping tool names to Tool objects.
365
+
366
+ Returns:
367
+ AssistantMessage containing the model's response, any tool calls,
368
+ and token usage statistics.
369
+
370
+ Raises:
371
+ ContextOverflowError: If the response is incomplete due to token limits.
372
+ """
373
+ # Convert messages to OpenResponses format
374
+ instructions, input_items = _to_open_responses_input(messages)
375
+
376
+ # Use provided instructions or fall back to default
377
+ final_instructions = instructions or self._default_instructions
378
+
379
+ # Build request kwargs
380
+ request_kwargs: dict[str, Any] = {
381
+ "model": self._model,
382
+ "input": input_items,
383
+ "max_output_tokens": self._max_tokens,
384
+ **self._kwargs,
385
+ }
386
+
387
+ # Add instructions if present
388
+ if final_instructions:
389
+ request_kwargs["instructions"] = final_instructions
390
+
391
+ # Add tools if provided
392
+ if tools:
393
+ request_kwargs["tools"] = _to_open_responses_tools(tools)
394
+ request_kwargs["tool_choice"] = "auto"
395
+
396
+ # Add reasoning effort if configured (for o1/o3 models)
397
+ if self._reasoning_effort:
398
+ request_kwargs["reasoning"] = {"effort": self._reasoning_effort}
399
+
400
+ # Make API call
401
+ response = await self._client.responses.create(**request_kwargs)
402
+
403
+ # Check for incomplete response (context overflow)
404
+ if response.status == "incomplete":
405
+ stop_reason = getattr(response, "incomplete_details", None)
406
+ raise ContextOverflowError(
407
+ f"Response incomplete for model {self.model_slug}: {stop_reason}. "
408
+ "Reduce max_tokens or message length and try again."
409
+ )
410
+
411
+ # Parse response output
412
+ content, tool_calls, reasoning = _parse_response_output(response.output)
413
+
414
+ # Parse token usage
415
+ usage = response.usage
416
+ input_tokens = usage.input_tokens if usage else 0
417
+ output_tokens = usage.output_tokens if usage else 0
418
+
419
+ # Handle reasoning tokens if available
420
+ reasoning_tokens = 0
421
+ if usage and hasattr(usage, "output_tokens_details") and usage.output_tokens_details:
422
+ reasoning_tokens = getattr(usage.output_tokens_details, "reasoning_tokens", 0) or 0
423
+ output_tokens = output_tokens - reasoning_tokens
424
+
425
+ return AssistantMessage(
426
+ reasoning=reasoning,
427
+ content=content,
428
+ tool_calls=tool_calls,
429
+ token_usage=TokenUsage(
430
+ input=input_tokens,
431
+ output=output_tokens,
432
+ reasoning=reasoning_tokens,
433
+ ),
434
+ )
stirrup/clients/utils.py CHANGED
@@ -12,6 +12,7 @@ from stirrup.core.models import (
12
12
  AudioContentBlock,
13
13
  ChatMessage,
14
14
  Content,
15
+ EmptyParams,
15
16
  ImageContentBlock,
16
17
  SystemMessage,
17
18
  Tool,
@@ -47,7 +48,7 @@ def to_openai_tools(tools: dict[str, Tool]) -> list[dict[str, Any]]:
47
48
  "name": t.name,
48
49
  "description": t.description,
49
50
  }
50
- if t.parameters is not None:
51
+ if t.parameters is not EmptyParams:
51
52
  function["parameters"] = t.parameters.model_json_schema()
52
53
  tool_payload: dict[str, Any] = {
53
54
  "type": "function",
@@ -139,6 +140,10 @@ def to_openai_messages(msgs: list[ChatMessage]) -> list[dict[str, Any]]:
139
140
  tool_dict = tool.model_dump()
140
141
  tool_dict["id"] = tool.tool_call_id
141
142
  tool_dict["type"] = "function"
143
+ if tool.signature is not None:
144
+ tool_dict["provider_specific_fields"] = {
145
+ "thought_signature": tool.signature,
146
+ }
142
147
  tool_dict["function"] = {
143
148
  "name": tool.name,
144
149
  "arguments": tool.arguments,
stirrup/constants.py CHANGED
@@ -1,14 +1,18 @@
1
+ from typing import Literal
2
+
1
3
  # Tool naming
2
- FINISH_TOOL_NAME = "finish"
4
+ FINISH_TOOL_NAME: Literal["finish"] = "finish"
3
5
 
4
6
  # Agent execution limits
5
7
  AGENT_MAX_TURNS = 30 # Maximum agent turns before forced termination
6
8
  CONTEXT_SUMMARIZATION_CUTOFF = 0.7 # Context window usage threshold (0.0-1.0) that triggers message summarization
9
+ TURNS_REMAINING_WARNING_THRESHOLD = 20
7
10
 
8
11
  # Media resolution limits
9
12
  RESOLUTION_1MP = 1_000_000 # 1 megapixel - default max resolution for images
10
13
  RESOLUTION_480P = 640 * 480 # 480p video resolution
11
14
 
12
15
  # Code execution
13
- SUBMISSION_SANDBOX_TIMEOUT = 60 * 10 # 10 minutes
16
+ SANDBOX_TIMEOUT = 60 * 10 # 10 minutes
17
+ SANDBOX_REQUEST_TIMEOUT = 60 * 3 # 3 minutes
14
18
  E2B_SANDBOX_TEMPLATE_ALIAS = "e2b-sandbox"