abstractcore 2.5.3__py3-none-any.whl → 2.6.2__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 (34) hide show
  1. abstractcore/__init__.py +7 -1
  2. abstractcore/architectures/detection.py +2 -2
  3. abstractcore/config/__init__.py +24 -1
  4. abstractcore/config/manager.py +47 -0
  5. abstractcore/core/retry.py +2 -2
  6. abstractcore/core/session.py +132 -1
  7. abstractcore/download.py +253 -0
  8. abstractcore/embeddings/manager.py +2 -2
  9. abstractcore/events/__init__.py +112 -1
  10. abstractcore/exceptions/__init__.py +49 -2
  11. abstractcore/media/processors/office_processor.py +2 -2
  12. abstractcore/media/utils/image_scaler.py +2 -2
  13. abstractcore/media/vision_fallback.py +2 -2
  14. abstractcore/providers/anthropic_provider.py +200 -6
  15. abstractcore/providers/base.py +100 -5
  16. abstractcore/providers/lmstudio_provider.py +254 -4
  17. abstractcore/providers/ollama_provider.py +253 -4
  18. abstractcore/providers/openai_provider.py +258 -6
  19. abstractcore/providers/registry.py +9 -1
  20. abstractcore/providers/streaming.py +2 -2
  21. abstractcore/tools/common_tools.py +2 -2
  22. abstractcore/tools/handler.py +2 -2
  23. abstractcore/tools/parser.py +2 -2
  24. abstractcore/tools/registry.py +2 -2
  25. abstractcore/tools/syntax_rewriter.py +2 -2
  26. abstractcore/tools/tag_rewriter.py +3 -3
  27. abstractcore/utils/self_fixes.py +2 -2
  28. abstractcore/utils/version.py +1 -1
  29. {abstractcore-2.5.3.dist-info → abstractcore-2.6.2.dist-info}/METADATA +162 -4
  30. {abstractcore-2.5.3.dist-info → abstractcore-2.6.2.dist-info}/RECORD +34 -33
  31. {abstractcore-2.5.3.dist-info → abstractcore-2.6.2.dist-info}/WHEEL +0 -0
  32. {abstractcore-2.5.3.dist-info → abstractcore-2.6.2.dist-info}/entry_points.txt +0 -0
  33. {abstractcore-2.5.3.dist-info → abstractcore-2.6.2.dist-info}/licenses/LICENSE +0 -0
  34. {abstractcore-2.5.3.dist-info → abstractcore-2.6.2.dist-info}/top_level.txt +0 -0
@@ -20,15 +20,17 @@ from enum import Enum
20
20
  from dataclasses import dataclass, field
21
21
  from datetime import datetime
22
22
  import uuid
23
+ import asyncio
23
24
 
24
25
 
25
26
  class EventType(Enum):
26
27
  """Minimal event system - clean, simple, efficient"""
27
28
 
28
- # Core events (4) - matches LangChain pattern
29
+ # Core events (5) - matches LangChain pattern + async progress
29
30
  GENERATION_STARTED = "generation_started" # Unified for streaming and non-streaming
30
31
  GENERATION_COMPLETED = "generation_completed" # Includes all metrics
31
32
  TOOL_STARTED = "tool_started" # Before tool execution
33
+ TOOL_PROGRESS = "tool_progress" # Real-time progress during tool execution
32
34
  TOOL_COMPLETED = "tool_completed" # After tool execution
33
35
 
34
36
  # Error handling (1)
@@ -60,6 +62,7 @@ class EventEmitter:
60
62
 
61
63
  def __init__(self):
62
64
  self._listeners: Dict[EventType, List[Callable]] = {}
65
+ self._async_listeners: Dict[EventType, List[Callable]] = {}
63
66
 
64
67
  def on(self, event_type: EventType, handler: Callable):
65
68
  """
@@ -141,6 +144,67 @@ class EventEmitter:
141
144
  }
142
145
  )
143
146
 
147
+ def on_async(self, event_type: EventType, handler: Callable):
148
+ """
149
+ Register an async event handler.
150
+
151
+ Args:
152
+ event_type: Type of event to listen for
153
+ handler: Async function to call when event occurs
154
+ """
155
+ if event_type not in self._async_listeners:
156
+ self._async_listeners[event_type] = []
157
+ self._async_listeners[event_type].append(handler)
158
+
159
+ async def emit_async(self, event_type: EventType, data: Dict[str, Any], source: Optional[str] = None, **kwargs) -> Event:
160
+ """
161
+ Emit an event asynchronously to all registered handlers.
162
+
163
+ Runs async handlers concurrently with asyncio.gather().
164
+ Also triggers sync handlers for backward compatibility.
165
+
166
+ Args:
167
+ event_type: Type of event
168
+ data: Event data
169
+ source: Source of the event
170
+ **kwargs: Additional event attributes (model_name, tokens, etc.)
171
+
172
+ Returns:
173
+ The event object
174
+ """
175
+ # Filter kwargs to only include valid Event fields
176
+ try:
177
+ valid_fields = set(Event.__dataclass_fields__.keys())
178
+ except AttributeError:
179
+ # Fallback for older Python versions
180
+ valid_fields = {'trace_id', 'span_id', 'request_id', 'duration_ms', 'model_name',
181
+ 'provider_name', 'tokens_input', 'tokens_output', 'cost_usd', 'metadata'}
182
+ filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields}
183
+
184
+ event = Event(
185
+ type=event_type,
186
+ timestamp=datetime.now(),
187
+ data=data,
188
+ source=source or self.__class__.__name__,
189
+ **filtered_kwargs
190
+ )
191
+
192
+ # Run async handlers concurrently
193
+ if event_type in self._async_listeners:
194
+ tasks = [handler(event) for handler in self._async_listeners[event_type]]
195
+ await asyncio.gather(*tasks, return_exceptions=True)
196
+
197
+ # Also run sync handlers (backward compatible)
198
+ if event_type in self._listeners:
199
+ for handler in self._listeners[event_type]:
200
+ try:
201
+ handler(event)
202
+ except Exception as e:
203
+ # Log error but don't stop event propagation
204
+ print(f"Error in event handler: {e}")
205
+
206
+ return event
207
+
144
208
 
145
209
  class GlobalEventBus:
146
210
  """
@@ -149,6 +213,7 @@ class GlobalEventBus:
149
213
  """
150
214
  _instance = None
151
215
  _listeners: Dict[EventType, List[Callable]] = {}
216
+ _async_listeners: Dict[EventType, List[Callable]] = {}
152
217
 
153
218
  def __new__(cls):
154
219
  if cls._instance is None:
@@ -199,6 +264,52 @@ class GlobalEventBus:
199
264
  def clear(cls):
200
265
  """Clear all global event handlers"""
201
266
  cls._listeners.clear()
267
+ cls._async_listeners.clear()
268
+
269
+ @classmethod
270
+ def on_async(cls, event_type: EventType, handler: Callable):
271
+ """Register a global async event handler"""
272
+ if event_type not in cls._async_listeners:
273
+ cls._async_listeners[event_type] = []
274
+ cls._async_listeners[event_type].append(handler)
275
+
276
+ @classmethod
277
+ async def emit_async(cls, event_type: EventType, data: Dict[str, Any], source: Optional[str] = None, **kwargs):
278
+ """
279
+ Emit a global event asynchronously.
280
+
281
+ Runs async handlers concurrently with asyncio.gather().
282
+ Also triggers sync handlers for backward compatibility.
283
+ """
284
+ # Filter kwargs to only include valid Event fields
285
+ try:
286
+ valid_fields = set(Event.__dataclass_fields__.keys())
287
+ except AttributeError:
288
+ # Fallback for older Python versions
289
+ valid_fields = {'trace_id', 'span_id', 'request_id', 'duration_ms', 'model_name',
290
+ 'provider_name', 'tokens_input', 'tokens_output', 'cost_usd', 'metadata'}
291
+ filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields}
292
+
293
+ event = Event(
294
+ type=event_type,
295
+ timestamp=datetime.now(),
296
+ data=data,
297
+ source=source or "GlobalEventBus",
298
+ **filtered_kwargs
299
+ )
300
+
301
+ # Run async handlers concurrently
302
+ if event_type in cls._async_listeners:
303
+ tasks = [handler(event) for handler in cls._async_listeners[event_type]]
304
+ await asyncio.gather(*tasks, return_exceptions=True)
305
+
306
+ # Also run sync handlers (backward compatible)
307
+ if event_type in cls._listeners:
308
+ for handler in cls._listeners[event_type]:
309
+ try:
310
+ handler(event)
311
+ except Exception as e:
312
+ print(f"Error in global event handler: {e}")
202
313
 
203
314
 
204
315
  # Convenience functions
@@ -106,10 +106,55 @@ def format_model_error(provider: str, invalid_model: str, available_models: list
106
106
  return message.rstrip()
107
107
 
108
108
 
109
+ def format_auth_error(provider: str, reason: str = None) -> str:
110
+ """
111
+ Format actionable authentication error with setup instructions.
112
+
113
+ Args:
114
+ provider: Provider name (e.g., "openai", "anthropic")
115
+ reason: Optional reason for auth failure
116
+
117
+ Returns:
118
+ Formatted error message with fix instructions
119
+ """
120
+ urls = {
121
+ "openai": "https://platform.openai.com/api-keys",
122
+ "anthropic": "https://console.anthropic.com/settings/keys",
123
+ }
124
+ msg = f"{provider.upper()} authentication failed"
125
+ if reason:
126
+ msg += f": {reason}"
127
+ msg += f"\nFix: abstractcore --set-api-key {provider} YOUR_KEY"
128
+ if provider.lower() in urls:
129
+ msg += f"\nGet key: {urls[provider.lower()]}"
130
+ return msg
131
+
132
+
133
+ def format_provider_error(provider: str, reason: str) -> str:
134
+ """
135
+ Format actionable provider unavailability error with setup instructions.
136
+
137
+ Args:
138
+ provider: Provider name (e.g., "ollama", "lmstudio")
139
+ reason: Reason for unavailability (e.g., "Connection refused")
140
+
141
+ Returns:
142
+ Formatted error message with setup instructions
143
+ """
144
+ instructions = {
145
+ "ollama": "Install: https://ollama.com/download\nStart: ollama serve",
146
+ "lmstudio": "Install: https://lmstudio.ai/\nEnable API in settings",
147
+ }
148
+ msg = f"Provider '{provider}' unavailable: {reason}"
149
+ if provider.lower() in instructions:
150
+ msg += f"\n{instructions[provider.lower()]}"
151
+ return msg
152
+
153
+
109
154
  # Export all exceptions for easy importing
110
155
  __all__ = [
111
156
  'AbstractCoreError',
112
- 'ProviderError',
157
+ 'ProviderError',
113
158
  'ProviderAPIError',
114
159
  'AuthenticationError',
115
160
  'Authentication', # Backward compatibility alias
@@ -121,5 +166,7 @@ __all__ = [
121
166
  'SessionError',
122
167
  'ConfigurationError',
123
168
  'ModelNotFoundError',
124
- 'format_model_error'
169
+ 'format_model_error',
170
+ 'format_auth_error',
171
+ 'format_provider_error'
125
172
  ]
@@ -6,13 +6,13 @@ This module provides comprehensive processing capabilities for Microsoft Office
6
6
  document processing in 2025.
7
7
  """
8
8
 
9
- import logging
10
9
  from pathlib import Path
11
10
  from typing import Optional, Dict, Any, List, Union, Tuple
12
11
  import json
13
12
 
14
13
  from ..base import BaseMediaHandler, MediaProcessingError
15
14
  from ..types import MediaContent, MediaType, ContentFormat, MediaProcessingResult
15
+ from ...utils.structured_logging import get_logger
16
16
 
17
17
 
18
18
  class OfficeProcessor(BaseMediaHandler):
@@ -36,7 +36,7 @@ class OfficeProcessor(BaseMediaHandler):
36
36
  **kwargs: Additional configuration options
37
37
  """
38
38
  super().__init__(**kwargs)
39
- self.logger = logging.getLogger(__name__)
39
+ self.logger = get_logger(__name__)
40
40
 
41
41
  # Configuration options
42
42
  self.extract_tables = kwargs.get('extract_tables', True)
@@ -8,7 +8,6 @@ and capabilities for vision models.
8
8
  from typing import Tuple, Optional, Union, Dict, Any
9
9
  from enum import Enum
10
10
  from pathlib import Path
11
- import logging
12
11
 
13
12
  try:
14
13
  from PIL import Image, ImageOps
@@ -17,6 +16,7 @@ except ImportError:
17
16
  PIL_AVAILABLE = False
18
17
 
19
18
  from ..base import MediaProcessingError
19
+ from ...utils.structured_logging import get_logger
20
20
 
21
21
 
22
22
  class ScalingMode(Enum):
@@ -36,7 +36,7 @@ class ModelOptimizedScaler:
36
36
  """
37
37
 
38
38
  def __init__(self):
39
- self.logger = logging.getLogger(__name__)
39
+ self.logger = get_logger(__name__)
40
40
 
41
41
  if not PIL_AVAILABLE:
42
42
  raise MediaProcessingError("PIL (Pillow) is required for image scaling")
@@ -5,11 +5,11 @@ Implements two-stage pipeline: vision model → description → text-only model
5
5
  Uses unified AbstractCore configuration system.
6
6
  """
7
7
 
8
- import logging
9
8
  from pathlib import Path
10
9
  from typing import Optional, Dict, Any
10
+ from ..utils.structured_logging import get_logger
11
11
 
12
- logger = logging.getLogger(__name__)
12
+ logger = get_logger(__name__)
13
13
 
14
14
 
15
15
  class VisionNotConfiguredError(Exception):
@@ -5,7 +5,7 @@ Anthropic provider implementation.
5
5
  import os
6
6
  import json
7
7
  import time
8
- from typing import List, Dict, Any, Optional, Union, Iterator, Type
8
+ from typing import List, Dict, Any, Optional, Union, Iterator, AsyncIterator, Type
9
9
 
10
10
  try:
11
11
  from pydantic import BaseModel
@@ -16,7 +16,7 @@ except ImportError:
16
16
  from .base import BaseProvider
17
17
  from ..core.types import GenerateResponse
18
18
  from ..media import MediaHandler
19
- from ..exceptions import AuthenticationError, ProviderAPIError, ModelNotFoundError, format_model_error
19
+ from ..exceptions import AuthenticationError, ProviderAPIError, ModelNotFoundError, format_model_error, format_auth_error
20
20
  from ..tools import UniversalToolHandler, execute_tools
21
21
  from ..events import EventType
22
22
 
@@ -30,7 +30,8 @@ except ImportError:
30
30
  class AnthropicProvider(BaseProvider):
31
31
  """Anthropic Claude API provider with full integration"""
32
32
 
33
- def __init__(self, model: str = "claude-3-haiku-20240307", api_key: Optional[str] = None, **kwargs):
33
+ def __init__(self, model: str = "claude-3-haiku-20240307", api_key: Optional[str] = None,
34
+ base_url: Optional[str] = None, **kwargs):
34
35
  super().__init__(model, **kwargs)
35
36
  self.provider = "anthropic"
36
37
 
@@ -42,8 +43,15 @@ class AnthropicProvider(BaseProvider):
42
43
  if not self.api_key:
43
44
  raise ValueError("Anthropic API key required. Set ANTHROPIC_API_KEY environment variable.")
44
45
 
45
- # Initialize client with timeout
46
- self.client = anthropic.Anthropic(api_key=self.api_key, timeout=self._timeout)
46
+ # Get base URL from param or environment
47
+ self.base_url = base_url or os.getenv("ANTHROPIC_BASE_URL")
48
+
49
+ # Initialize client with timeout and optional base_url
50
+ client_kwargs = {"api_key": self.api_key, "timeout": self._timeout}
51
+ if self.base_url:
52
+ client_kwargs["base_url"] = self.base_url
53
+ self.client = anthropic.Anthropic(**client_kwargs)
54
+ self._async_client = None # Lazy-loaded async client
47
55
 
48
56
  # Initialize tool handler
49
57
  self.tool_handler = UniversalToolHandler(model)
@@ -56,6 +64,16 @@ class AnthropicProvider(BaseProvider):
56
64
  """Public generate method that includes telemetry"""
57
65
  return self.generate_with_telemetry(*args, **kwargs)
58
66
 
67
+ @property
68
+ def async_client(self):
69
+ """Lazy-load AsyncAnthropic client for native async operations."""
70
+ if self._async_client is None:
71
+ client_kwargs = {"api_key": self.api_key, "timeout": self._timeout}
72
+ if self.base_url:
73
+ client_kwargs["base_url"] = self.base_url
74
+ self._async_client = anthropic.AsyncAnthropic(**client_kwargs)
75
+ return self._async_client
76
+
59
77
  def _generate_internal(self,
60
78
  prompt: str,
61
79
  messages: Optional[List[Dict[str, str]]] = None,
@@ -207,7 +225,7 @@ class AnthropicProvider(BaseProvider):
207
225
  error_str = str(e).lower()
208
226
 
209
227
  if 'api_key' in error_str or 'authentication' in error_str:
210
- raise AuthenticationError(f"Anthropic authentication failed: {str(e)}")
228
+ raise AuthenticationError(format_auth_error("anthropic", str(e)))
211
229
  elif ('not_found_error' in error_str and 'model:' in error_str) or '404' in error_str:
212
230
  # Model not found - show available models
213
231
  available_models = self.list_available_models(api_key=self.api_key)
@@ -216,6 +234,182 @@ class AnthropicProvider(BaseProvider):
216
234
  else:
217
235
  raise ProviderAPIError(f"Anthropic API error: {str(e)}")
218
236
 
237
+ async def _agenerate_internal(self,
238
+ prompt: str,
239
+ messages: Optional[List[Dict[str, str]]] = None,
240
+ system_prompt: Optional[str] = None,
241
+ tools: Optional[List[Dict[str, Any]]] = None,
242
+ media: Optional[List['MediaContent']] = None,
243
+ stream: bool = False,
244
+ response_model: Optional[Type[BaseModel]] = None,
245
+ **kwargs) -> Union[GenerateResponse, AsyncIterator[GenerateResponse]]:
246
+ """Native async implementation using AsyncAnthropic - 3-10x faster for batch operations."""
247
+
248
+ # Build messages array (same logic as sync)
249
+ api_messages = []
250
+
251
+ # Add conversation history
252
+ if messages:
253
+ for msg in messages:
254
+ # Skip system messages as they're handled separately
255
+ if msg.get("role") != "system":
256
+ # Convert assistant role if needed
257
+ role = msg["role"]
258
+ if role == "assistant":
259
+ api_messages.append({
260
+ "role": "assistant",
261
+ "content": msg["content"]
262
+ })
263
+ else:
264
+ api_messages.append({
265
+ "role": "user",
266
+ "content": msg["content"]
267
+ })
268
+
269
+ # Add current prompt as user message
270
+ if prompt and prompt not in [msg.get("content") for msg in (messages or [])]:
271
+ # Handle multimodal message with media content
272
+ if media:
273
+ try:
274
+ from ..media.handlers import AnthropicMediaHandler
275
+ media_handler = AnthropicMediaHandler(self.model_capabilities)
276
+
277
+ # Create multimodal message combining text and media
278
+ multimodal_message = media_handler.create_multimodal_message(prompt, media)
279
+ api_messages.append(multimodal_message)
280
+ except ImportError:
281
+ self.logger.warning("Media processing not available. Install with: pip install abstractcore[media]")
282
+ api_messages.append({"role": "user", "content": prompt})
283
+ except Exception as e:
284
+ self.logger.warning(f"Failed to process media content: {e}")
285
+ api_messages.append({"role": "user", "content": prompt})
286
+ else:
287
+ api_messages.append({"role": "user", "content": prompt})
288
+
289
+ # Prepare API call parameters (same logic as sync)
290
+ generation_kwargs = self._prepare_generation_kwargs(**kwargs)
291
+ max_output_tokens = self._get_provider_max_tokens_param(generation_kwargs)
292
+
293
+ call_params = {
294
+ "model": self.model,
295
+ "messages": api_messages,
296
+ "max_tokens": max_output_tokens,
297
+ "temperature": kwargs.get("temperature", self.temperature),
298
+ "stream": stream
299
+ }
300
+
301
+ # Add system prompt if provided (Anthropic-specific: separate parameter)
302
+ if system_prompt:
303
+ call_params["system"] = system_prompt
304
+
305
+ # Add top_p if specified
306
+ if kwargs.get("top_p") or self.top_p < 1.0:
307
+ call_params["top_p"] = kwargs.get("top_p", self.top_p)
308
+
309
+ # Add top_k if specified
310
+ if kwargs.get("top_k") or self.top_k:
311
+ call_params["top_k"] = kwargs.get("top_k", self.top_k)
312
+
313
+ # Handle seed parameter (Anthropic doesn't support seed natively)
314
+ seed_value = kwargs.get("seed", self.seed)
315
+ if seed_value is not None:
316
+ import warnings
317
+ warnings.warn(
318
+ f"Seed parameter ({seed_value}) is not supported by Anthropic Claude API. "
319
+ f"For deterministic outputs, use temperature=0.0 which may provide more consistent results, "
320
+ f"though true determinism is not guaranteed.",
321
+ UserWarning,
322
+ stacklevel=3
323
+ )
324
+ self.logger.warning(f"Seed {seed_value} requested but not supported by Anthropic API")
325
+
326
+ # Handle structured output using the "tool trick"
327
+ structured_tool_name = None
328
+ if response_model and PYDANTIC_AVAILABLE:
329
+ structured_tool = self._create_structured_output_tool(response_model)
330
+
331
+ if tools:
332
+ tools = list(tools) + [structured_tool]
333
+ else:
334
+ tools = [structured_tool]
335
+
336
+ structured_tool_name = structured_tool["name"]
337
+
338
+ if api_messages and api_messages[-1]["role"] == "user":
339
+ api_messages[-1]["content"] += f"\n\nPlease use the {structured_tool_name} tool to provide your response."
340
+
341
+ # Add tools if provided
342
+ if tools:
343
+ if self.tool_handler.supports_native:
344
+ call_params["tools"] = self._format_tools_for_anthropic(tools)
345
+
346
+ if structured_tool_name:
347
+ call_params["tool_choice"] = {"type": "tool", "name": structured_tool_name}
348
+ elif kwargs.get("tool_choice"):
349
+ call_params["tool_choice"] = {"type": kwargs.get("tool_choice", "auto")}
350
+ else:
351
+ tool_prompt = self.tool_handler.format_tools_prompt(tools)
352
+ if call_params.get("system"):
353
+ call_params["system"] += f"\n\n{tool_prompt}"
354
+ else:
355
+ call_params["system"] = tool_prompt
356
+
357
+ # Make async API call
358
+ try:
359
+ if stream:
360
+ return self._async_stream_response(call_params, tools)
361
+ else:
362
+ start_time = time.time()
363
+ response = await self.async_client.messages.create(**call_params)
364
+ gen_time = round((time.time() - start_time) * 1000, 1)
365
+
366
+ formatted = self._format_response(response)
367
+ formatted.gen_time = gen_time
368
+
369
+ if tools and (formatted.has_tool_calls() or
370
+ (self.tool_handler.supports_prompted and formatted.content)):
371
+ formatted = self._handle_tool_execution(formatted, tools)
372
+
373
+ return formatted
374
+ except Exception as e:
375
+ error_str = str(e).lower()
376
+
377
+ if 'api_key' in error_str or 'authentication' in error_str:
378
+ raise AuthenticationError(format_auth_error("anthropic", str(e)))
379
+ elif ('not_found_error' in error_str and 'model:' in error_str) or '404' in error_str:
380
+ available_models = self.list_available_models(api_key=self.api_key)
381
+ error_message = format_model_error("Anthropic", self.model, available_models)
382
+ raise ModelNotFoundError(error_message)
383
+ else:
384
+ raise ProviderAPIError(f"Anthropic API error: {str(e)}")
385
+
386
+ async def _async_stream_response(self, call_params: Dict[str, Any], tools: Optional[List[Dict[str, Any]]] = None) -> AsyncIterator[GenerateResponse]:
387
+ """Native async streaming with Anthropic's context manager pattern."""
388
+ stream_params = {k: v for k, v in call_params.items() if k != 'stream'}
389
+
390
+ try:
391
+ async with self.async_client.messages.stream(**stream_params) as stream:
392
+ async for chunk in stream:
393
+ yield GenerateResponse(
394
+ content=getattr(chunk, 'content', ''),
395
+ model=self.model,
396
+ finish_reason=getattr(chunk, 'finish_reason', None),
397
+ raw_response=chunk
398
+ )
399
+ except Exception as e:
400
+ raise ProviderAPIError(f"Anthropic streaming error: {str(e)}")
401
+
402
+ def unload(self) -> None:
403
+ """Close async client if it was created."""
404
+ if self._async_client is not None:
405
+ import asyncio
406
+ try:
407
+ loop = asyncio.get_running_loop()
408
+ loop.create_task(self._async_client.close())
409
+ except RuntimeError:
410
+ import asyncio
411
+ asyncio.run(self._async_client.close())
412
+
219
413
  def _format_tools_for_anthropic(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
220
414
  """Format tools for Anthropic API format"""
221
415
  formatted_tools = []
@@ -4,8 +4,9 @@ Base provider with integrated telemetry, events, and exception handling.
4
4
 
5
5
  import time
6
6
  import uuid
7
+ import asyncio
7
8
  from collections import deque
8
- from typing import List, Dict, Any, Optional, Union, Iterator, Type
9
+ from typing import List, Dict, Any, Optional, Union, Iterator, AsyncIterator, Type
9
10
  from abc import ABC, abstractmethod
10
11
 
11
12
  try:
@@ -1440,9 +1441,9 @@ Please provide a structured response."""
1440
1441
  **kwargs) -> Union[GenerateResponse, Iterator[GenerateResponse], BaseModel]:
1441
1442
  """
1442
1443
  Generate response from the LLM.
1443
-
1444
+
1444
1445
  This method implements the AbstractCoreInterface and delegates to generate_with_telemetry.
1445
-
1446
+
1446
1447
  Args:
1447
1448
  prompt: The input prompt
1448
1449
  messages: Optional conversation history
@@ -1450,7 +1451,7 @@ Please provide a structured response."""
1450
1451
  tools: Optional list of available tools
1451
1452
  stream: Whether to stream the response
1452
1453
  **kwargs: Additional provider-specific parameters (including response_model)
1453
-
1454
+
1454
1455
  Returns:
1455
1456
  GenerateResponse, iterator of GenerateResponse for streaming, or BaseModel for structured output
1456
1457
  """
@@ -1461,4 +1462,98 @@ Please provide a structured response."""
1461
1462
  tools=tools,
1462
1463
  stream=stream,
1463
1464
  **kwargs
1464
- )
1465
+ )
1466
+
1467
+ async def agenerate(self,
1468
+ prompt: str = "",
1469
+ messages: Optional[List[Dict]] = None,
1470
+ system_prompt: Optional[str] = None,
1471
+ tools: Optional[List] = None,
1472
+ media: Optional[List] = None,
1473
+ stream: bool = False,
1474
+ **kwargs) -> Union[GenerateResponse, AsyncIterator[GenerateResponse], BaseModel]:
1475
+ """
1476
+ Async generation - works with all providers.
1477
+
1478
+ Calls _agenerate_internal() which can be overridden for native async.
1479
+ Default implementation uses asyncio.to_thread() fallback.
1480
+
1481
+ Args:
1482
+ prompt: Text prompt
1483
+ messages: Conversation history
1484
+ system_prompt: System instructions
1485
+ tools: Available tools
1486
+ media: Media attachments
1487
+ stream: Enable streaming
1488
+ **kwargs: Additional generation parameters (including response_model)
1489
+
1490
+ Returns:
1491
+ GenerateResponse, AsyncIterator[GenerateResponse] for streaming, or BaseModel for structured output
1492
+ """
1493
+ return await self._agenerate_internal(
1494
+ prompt, messages, system_prompt, tools, media, stream, **kwargs
1495
+ )
1496
+
1497
+ async def _agenerate_internal(self,
1498
+ prompt: str,
1499
+ messages: Optional[List[Dict]],
1500
+ system_prompt: Optional[str],
1501
+ tools: Optional[List],
1502
+ media: Optional[List],
1503
+ stream: bool,
1504
+ **kwargs) -> Union[GenerateResponse, AsyncIterator[GenerateResponse], BaseModel]:
1505
+ """
1506
+ Internal async generation method.
1507
+
1508
+ Default implementation: Uses asyncio.to_thread() to run sync generate().
1509
+ Providers override this for native async (3-10x faster for batch operations).
1510
+
1511
+ Args:
1512
+ prompt: Text prompt
1513
+ messages: Conversation history
1514
+ system_prompt: System instructions
1515
+ tools: Available tools
1516
+ media: Media attachments
1517
+ stream: Enable streaming
1518
+ **kwargs: Additional generation parameters
1519
+
1520
+ Returns:
1521
+ GenerateResponse, AsyncIterator[GenerateResponse] for streaming, or BaseModel for structured output
1522
+ """
1523
+ if stream:
1524
+ # Return async iterator for streaming
1525
+ return self._async_stream_generate(
1526
+ prompt, messages, system_prompt, tools, media, **kwargs
1527
+ )
1528
+ else:
1529
+ # Run sync generate in thread pool (fallback)
1530
+ return await asyncio.to_thread(
1531
+ self.generate,
1532
+ prompt, messages, system_prompt, tools, stream, **kwargs
1533
+ )
1534
+
1535
+ async def _async_stream_generate(self,
1536
+ prompt: str,
1537
+ messages: Optional[List[Dict]],
1538
+ system_prompt: Optional[str],
1539
+ tools: Optional[List],
1540
+ media: Optional[List],
1541
+ **kwargs) -> AsyncIterator[GenerateResponse]:
1542
+ """
1543
+ Async streaming generator.
1544
+
1545
+ Wraps sync streaming in async iterator, yielding control to event loop.
1546
+ """
1547
+ # Get sync generator in thread pool
1548
+ def get_sync_stream():
1549
+ return self.generate(
1550
+ prompt, messages, system_prompt, tools,
1551
+ stream=True, **kwargs
1552
+ )
1553
+
1554
+ sync_gen = await asyncio.to_thread(get_sync_stream)
1555
+
1556
+ # Yield chunks asynchronously
1557
+ for chunk in sync_gen:
1558
+ yield chunk
1559
+ await asyncio.sleep(0) # Yield control to event loop