abstractcore 2.5.3__py3-none-any.whl → 2.6.0__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 (31) hide show
  1. abstractcore/__init__.py +7 -1
  2. abstractcore/architectures/detection.py +2 -2
  3. abstractcore/core/retry.py +2 -2
  4. abstractcore/core/session.py +132 -1
  5. abstractcore/download.py +253 -0
  6. abstractcore/embeddings/manager.py +2 -2
  7. abstractcore/events/__init__.py +112 -1
  8. abstractcore/exceptions/__init__.py +49 -2
  9. abstractcore/media/processors/office_processor.py +2 -2
  10. abstractcore/media/utils/image_scaler.py +2 -2
  11. abstractcore/media/vision_fallback.py +2 -2
  12. abstractcore/providers/anthropic_provider.py +200 -6
  13. abstractcore/providers/base.py +100 -5
  14. abstractcore/providers/lmstudio_provider.py +246 -2
  15. abstractcore/providers/ollama_provider.py +244 -2
  16. abstractcore/providers/openai_provider.py +258 -6
  17. abstractcore/providers/streaming.py +2 -2
  18. abstractcore/tools/common_tools.py +2 -2
  19. abstractcore/tools/handler.py +2 -2
  20. abstractcore/tools/parser.py +2 -2
  21. abstractcore/tools/registry.py +2 -2
  22. abstractcore/tools/syntax_rewriter.py +2 -2
  23. abstractcore/tools/tag_rewriter.py +3 -3
  24. abstractcore/utils/self_fixes.py +2 -2
  25. abstractcore/utils/version.py +1 -1
  26. {abstractcore-2.5.3.dist-info → abstractcore-2.6.0.dist-info}/METADATA +102 -4
  27. {abstractcore-2.5.3.dist-info → abstractcore-2.6.0.dist-info}/RECORD +31 -30
  28. {abstractcore-2.5.3.dist-info → abstractcore-2.6.0.dist-info}/WHEEL +0 -0
  29. {abstractcore-2.5.3.dist-info → abstractcore-2.6.0.dist-info}/entry_points.txt +0 -0
  30. {abstractcore-2.5.3.dist-info → abstractcore-2.6.0.dist-info}/licenses/LICENSE +0 -0
  31. {abstractcore-2.5.3.dist-info → abstractcore-2.6.0.dist-info}/top_level.txt +0 -0
@@ -5,7 +5,7 @@ OpenAI 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 OpenAIProvider(BaseProvider):
31
31
  """OpenAI API provider with full integration"""
32
32
 
33
- def __init__(self, model: str = "gpt-3.5-turbo", api_key: Optional[str] = None, **kwargs):
33
+ def __init__(self, model: str = "gpt-3.5-turbo", api_key: Optional[str] = None,
34
+ base_url: Optional[str] = None, **kwargs):
34
35
  super().__init__(model, **kwargs)
35
36
  self.provider = "openai"
36
37
 
@@ -42,8 +43,15 @@ class OpenAIProvider(BaseProvider):
42
43
  if not self.api_key:
43
44
  raise ValueError("OpenAI API key required. Set OPENAI_API_KEY environment variable.")
44
45
 
45
- # Initialize client with timeout
46
- self.client = openai.OpenAI(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("OPENAI_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 = openai.OpenAI(**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)
@@ -60,6 +68,16 @@ class OpenAIProvider(BaseProvider):
60
68
  """Public generate method that includes telemetry"""
61
69
  return self.generate_with_telemetry(*args, **kwargs)
62
70
 
71
+ @property
72
+ def async_client(self):
73
+ """Lazy-load AsyncOpenAI client for native async operations."""
74
+ if self._async_client is None:
75
+ client_kwargs = {"api_key": self.api_key, "timeout": self._timeout}
76
+ if self.base_url:
77
+ client_kwargs["base_url"] = self.base_url
78
+ self._async_client = openai.AsyncOpenAI(**client_kwargs)
79
+ return self._async_client
80
+
63
81
  def _generate_internal(self,
64
82
  prompt: str,
65
83
  messages: Optional[List[Dict[str, str]]] = None,
@@ -188,6 +206,228 @@ class OpenAIProvider(BaseProvider):
188
206
  # Model validation is done at initialization, so this is likely an API error
189
207
  raise ProviderAPIError(f"OpenAI API error: {str(e)}")
190
208
 
209
+ async def _agenerate_internal(self,
210
+ prompt: str,
211
+ messages: Optional[List[Dict[str, str]]] = None,
212
+ system_prompt: Optional[str] = None,
213
+ tools: Optional[List[Dict[str, Any]]] = None,
214
+ media: Optional[List['MediaContent']] = None,
215
+ stream: bool = False,
216
+ response_model: Optional[Type[BaseModel]] = None,
217
+ **kwargs) -> Union[GenerateResponse, AsyncIterator[GenerateResponse]]:
218
+ """Native async implementation using AsyncOpenAI - 3-10x faster for batch operations."""
219
+
220
+ # Build messages array (same logic as sync)
221
+ api_messages = []
222
+
223
+ # Add system message if provided
224
+ if system_prompt:
225
+ api_messages.append({"role": "system", "content": system_prompt})
226
+
227
+ # Add conversation history
228
+ if messages:
229
+ for msg in messages:
230
+ # Skip system messages as they're handled separately
231
+ if msg.get("role") != "system":
232
+ api_messages.append({
233
+ "role": msg["role"],
234
+ "content": msg["content"]
235
+ })
236
+
237
+ # Add current prompt as user message
238
+ if prompt and prompt not in [msg.get("content") for msg in (messages or [])]:
239
+ # Handle multimodal message with media content
240
+ if media:
241
+ try:
242
+ from ..media.handlers import OpenAIMediaHandler
243
+ media_handler = OpenAIMediaHandler(self.model_capabilities)
244
+
245
+ # Create multimodal message combining text and media
246
+ multimodal_message = media_handler.create_multimodal_message(prompt, media)
247
+ api_messages.append(multimodal_message)
248
+ except ImportError:
249
+ self.logger.warning("Media processing not available. Install with: pip install abstractcore[media]")
250
+ api_messages.append({"role": "user", "content": prompt})
251
+ except Exception as e:
252
+ self.logger.warning(f"Failed to process media content: {e}")
253
+ api_messages.append({"role": "user", "content": prompt})
254
+ else:
255
+ api_messages.append({"role": "user", "content": prompt})
256
+
257
+ # Prepare API call parameters using unified system (same logic as sync)
258
+ generation_kwargs = self._prepare_generation_kwargs(**kwargs)
259
+ max_output_tokens = self._get_provider_max_tokens_param(generation_kwargs)
260
+
261
+ call_params = {
262
+ "model": self.model,
263
+ "messages": api_messages,
264
+ "stream": stream
265
+ }
266
+
267
+ # Add parameters that are supported by this model
268
+ if not self._is_reasoning_model():
269
+ # Reasoning models (o1, gpt-5) don't support many parameters
270
+ call_params["temperature"] = kwargs.get("temperature", self.temperature)
271
+ call_params["top_p"] = kwargs.get("top_p", self.top_p)
272
+ call_params["frequency_penalty"] = kwargs.get("frequency_penalty", self.frequency_penalty)
273
+ call_params["presence_penalty"] = kwargs.get("presence_penalty", self.presence_penalty)
274
+
275
+ # Add seed if provided (OpenAI supports seed for deterministic outputs)
276
+ seed_value = kwargs.get("seed", self.seed)
277
+ if seed_value is not None:
278
+ call_params["seed"] = seed_value
279
+
280
+ # Handle different token parameter names for different model families
281
+ if self._uses_max_completion_tokens():
282
+ call_params["max_completion_tokens"] = max_output_tokens
283
+ else:
284
+ call_params["max_tokens"] = max_output_tokens
285
+
286
+ # Add tools if provided (convert to native format)
287
+ if tools:
288
+ # Convert tools to native format for OpenAI API
289
+ if self.tool_handler.supports_native:
290
+ call_params["tools"] = self.tool_handler.prepare_tools_for_native(tools)
291
+ call_params["tool_choice"] = kwargs.get("tool_choice", "auto")
292
+ else:
293
+ # Fallback to manual formatting
294
+ call_params["tools"] = self._format_tools_for_openai(tools)
295
+ call_params["tool_choice"] = kwargs.get("tool_choice", "auto")
296
+
297
+ # Add structured output support (OpenAI native)
298
+ if response_model and PYDANTIC_AVAILABLE:
299
+ if self._supports_structured_output():
300
+ json_schema = response_model.model_json_schema()
301
+
302
+ # OpenAI requires additionalProperties: false for strict mode
303
+ self._ensure_strict_schema(json_schema)
304
+
305
+ call_params["response_format"] = {
306
+ "type": "json_schema",
307
+ "json_schema": {
308
+ "name": response_model.__name__,
309
+ "strict": True,
310
+ "schema": json_schema
311
+ }
312
+ }
313
+
314
+ # Make async API call with proper exception handling
315
+ try:
316
+ if stream:
317
+ return self._async_stream_response(call_params, tools)
318
+ else:
319
+ # Track generation time
320
+ start_time = time.time()
321
+ response = await self.async_client.chat.completions.create(**call_params)
322
+ gen_time = round((time.time() - start_time) * 1000, 1)
323
+
324
+ formatted = self._format_response(response)
325
+ # Add generation time to response
326
+ formatted.gen_time = gen_time
327
+
328
+ # Handle tool execution for OpenAI native responses
329
+ if tools and formatted.has_tool_calls():
330
+ formatted = self._handle_tool_execution(formatted, tools)
331
+
332
+ return formatted
333
+ except Exception as e:
334
+ # Model validation is done at initialization, so this is likely an API error
335
+ raise ProviderAPIError(f"OpenAI API error: {str(e)}")
336
+
337
+ async def _async_stream_response(self, call_params: Dict[str, Any], tools: Optional[List[Dict[str, Any]]] = None) -> AsyncIterator[GenerateResponse]:
338
+ """Native async streaming responses from OpenAI."""
339
+ try:
340
+ stream = await self.async_client.chat.completions.create(**call_params)
341
+ except Exception as e:
342
+ # Model validation is done at initialization, so this is likely an API error
343
+ raise ProviderAPIError(f"OpenAI API error: {str(e)}")
344
+
345
+ # For streaming with tools, we need to collect the complete response
346
+ collected_content = ""
347
+ collected_tool_calls = {} # Use dict to merge streaming chunks by tool call ID
348
+ final_response = None
349
+
350
+ async for chunk in stream:
351
+ choice = chunk.choices[0] if chunk.choices else None
352
+ if not choice:
353
+ continue
354
+
355
+ delta = choice.delta
356
+ content = getattr(delta, 'content', None) or ""
357
+ collected_content += content
358
+
359
+ # Handle tool calls in streaming - merge incomplete chunks
360
+ if hasattr(delta, 'tool_calls') and delta.tool_calls:
361
+ for tc in delta.tool_calls:
362
+ tc_id = getattr(tc, 'id', None) or getattr(tc, 'index', 0)
363
+
364
+ # Initialize or get existing tool call
365
+ if tc_id not in collected_tool_calls:
366
+ collected_tool_calls[tc_id] = {
367
+ "id": getattr(tc, 'id', None),
368
+ "type": getattr(tc, 'type', 'function'),
369
+ "name": None,
370
+ "arguments": ""
371
+ }
372
+
373
+ # Update with new data from this chunk
374
+ if hasattr(tc, 'function'):
375
+ if hasattr(tc.function, 'name') and tc.function.name:
376
+ collected_tool_calls[tc_id]["name"] = tc.function.name
377
+ if hasattr(tc.function, 'arguments') and tc.function.arguments:
378
+ collected_tool_calls[tc_id]["arguments"] += tc.function.arguments
379
+
380
+ # Create chunk response
381
+ chunk_response = GenerateResponse(
382
+ content=content,
383
+ raw_response=chunk,
384
+ model=chunk.model,
385
+ finish_reason=choice.finish_reason,
386
+ tool_calls=None # Don't include incomplete tool calls in chunks
387
+ )
388
+
389
+ # If this is the final chunk and we have tools, handle tool execution
390
+ if choice.finish_reason and tools and collected_tool_calls:
391
+ # Convert dict to list and filter out incomplete tool calls
392
+ complete_tool_calls = []
393
+ for tc in collected_tool_calls.values():
394
+ if tc["name"] and tc["arguments"] is not None: # Include tool calls with empty args
395
+ complete_tool_calls.append(tc)
396
+
397
+ # Create complete response for tool processing
398
+ complete_response = GenerateResponse(
399
+ content=collected_content,
400
+ raw_response=chunk,
401
+ model=chunk.model,
402
+ finish_reason=choice.finish_reason,
403
+ tool_calls=complete_tool_calls if complete_tool_calls else None
404
+ )
405
+
406
+ # Handle tool execution
407
+ final_response = self._handle_tool_execution(complete_response, tools)
408
+
409
+ # If tools were executed, yield the tool results as final chunk
410
+ if final_response.content != collected_content:
411
+ tool_results_content = final_response.content[len(collected_content):]
412
+ yield GenerateResponse(
413
+ content=tool_results_content,
414
+ raw_response=chunk,
415
+ model=chunk.model,
416
+ finish_reason=choice.finish_reason,
417
+ tool_calls=None
418
+ )
419
+ else:
420
+ # No tools executed but response was processed - yield final response content
421
+ yield GenerateResponse(
422
+ content=final_response.content,
423
+ raw_response=chunk,
424
+ model=chunk.model,
425
+ finish_reason=choice.finish_reason,
426
+ tool_calls=complete_tool_calls if complete_tool_calls else None
427
+ )
428
+ else:
429
+ yield chunk_response
430
+
191
431
  def _format_tools_for_openai(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
192
432
  """Format tools for OpenAI API format"""
193
433
  formatted_tools = []
@@ -391,6 +631,18 @@ class OpenAIProvider(BaseProvider):
391
631
  return False
392
632
  return True
393
633
 
634
+ def unload(self) -> None:
635
+ """Close async client if it was created."""
636
+ if self._async_client is not None:
637
+ import asyncio
638
+ try:
639
+ loop = asyncio.get_running_loop()
640
+ loop.create_task(self._async_client.close())
641
+ except RuntimeError:
642
+ # No running loop, close synchronously
643
+ import asyncio
644
+ asyncio.run(self._async_client.close())
645
+
394
646
  def _validate_model_exists(self):
395
647
  """Preflight check to validate model exists before any generation"""
396
648
  try:
@@ -410,7 +662,7 @@ class OpenAIProvider(BaseProvider):
410
662
  # For other errors (like API failures), handle gracefully
411
663
  error_str = str(e).lower()
412
664
  if 'api_key' in error_str or 'authentication' in error_str:
413
- raise AuthenticationError(f"OpenAI authentication failed: {str(e)}")
665
+ raise AuthenticationError(format_auth_error("openai", str(e)))
414
666
  # For other API errors during preflight, continue (model might work)
415
667
  # This allows for cases where models.list() fails but generation works
416
668
 
@@ -7,15 +7,15 @@ while maintaining real-time streaming performance, with proper tag rewriting sup
7
7
 
8
8
  import json
9
9
  import re
10
- import logging
11
10
  import uuid
12
11
  from typing import List, Dict, Any, Optional, Iterator, Tuple
13
12
  from enum import Enum
14
13
 
15
14
  from ..core.types import GenerateResponse
16
15
  from ..tools.core import ToolCall
16
+ from ..utils.structured_logging import get_logger
17
17
 
18
- logger = logging.getLogger(__name__)
18
+ logger = get_logger(__name__)
19
19
 
20
20
 
21
21
  class ToolDetectionState(Enum):
@@ -12,7 +12,6 @@ import subprocess
12
12
  import requests
13
13
  from pathlib import Path
14
14
  from typing import Optional, Dict, Any, Union
15
- import logging
16
15
  import platform
17
16
  import re
18
17
  import time
@@ -43,8 +42,9 @@ except ImportError:
43
42
 
44
43
  # Import our enhanced tool decorator
45
44
  from abstractcore.tools.core import tool
45
+ from abstractcore.utils.structured_logging import get_logger
46
46
 
47
- logger = logging.getLogger(__name__)
47
+ logger = get_logger(__name__)
48
48
 
49
49
  # File Operations
50
50
  @tool(
@@ -6,14 +6,14 @@ across all models, whether they have native tool APIs or require prompting.
6
6
  """
7
7
 
8
8
  import json
9
- import logging
10
9
  from typing import List, Dict, Any, Optional, Union, Callable
11
10
 
12
11
  from ..architectures import detect_architecture, get_model_capabilities, get_architecture_format
13
12
  from .core import ToolDefinition, ToolCall, ToolCallResponse, ToolResult
14
13
  from .parser import detect_tool_calls, parse_tool_calls, format_tool_prompt
14
+ from ..utils.structured_logging import get_logger
15
15
 
16
- logger = logging.getLogger(__name__)
16
+ logger = get_logger(__name__)
17
17
 
18
18
 
19
19
  class UniversalToolHandler:
@@ -7,14 +7,14 @@ responses based on their architecture.
7
7
 
8
8
  import re
9
9
  import json
10
- import logging
11
10
  from typing import List, Optional, Dict, Any
12
11
  from enum import Enum
13
12
 
14
13
  from .core import ToolCall, ToolDefinition
15
14
  from ..architectures import detect_architecture, get_architecture_format
15
+ from ..utils.structured_logging import get_logger
16
16
 
17
- logger = logging.getLogger(__name__)
17
+ logger = get_logger(__name__)
18
18
 
19
19
 
20
20
  class ToolFormat(Enum):
@@ -5,15 +5,15 @@ This module provides a centralized registry for managing available tools
5
5
  and executing them safely.
6
6
  """
7
7
 
8
- import logging
9
8
  import time
10
9
  from typing import Dict, List, Any, Callable, Optional, Union
11
10
  from functools import wraps
12
11
 
13
12
  from .core import ToolDefinition, ToolCall, ToolResult
14
13
  from ..events import EventType, emit_global, create_tool_event
14
+ from ..utils.structured_logging import get_logger
15
15
 
16
- logger = logging.getLogger(__name__)
16
+ logger = get_logger(__name__)
17
17
 
18
18
 
19
19
  class ToolRegistry:
@@ -8,15 +8,15 @@ Supports multiple target formats including OpenAI, Codex, and custom agent forma
8
8
  import re
9
9
  import json
10
10
  import uuid
11
- import logging
12
11
  from typing import List, Dict, Any, Optional, Union
13
12
  from dataclasses import dataclass
14
13
  from enum import Enum
15
14
 
16
15
  from .core import ToolCall
17
16
  from .parser import parse_tool_calls
17
+ from ..utils.structured_logging import get_logger
18
18
 
19
- logger = logging.getLogger(__name__)
19
+ logger = get_logger(__name__)
20
20
 
21
21
 
22
22
  class SyntaxFormat(Enum):
@@ -9,6 +9,9 @@ import re
9
9
  import json
10
10
  from typing import Dict, Any, Optional, Tuple, List
11
11
  from dataclasses import dataclass
12
+ from ..utils.structured_logging import get_logger
13
+
14
+ logger = get_logger(__name__)
12
15
 
13
16
 
14
17
  @dataclass
@@ -161,9 +164,6 @@ class ToolCallTagRewriter:
161
164
  Returns:
162
165
  Text with rewritten tool call tags
163
166
  """
164
- import logging
165
- logger = logging.getLogger(__name__)
166
-
167
167
  logger.debug(f"rewrite_text called with text: {text[:100] if text else None}")
168
168
  logger.debug(f"Target output tags: start='{self._output_start_tag}', end='{self._output_end_tag}'")
169
169
 
@@ -8,9 +8,9 @@ before giving up on parsing.
8
8
  import json
9
9
  import re
10
10
  from typing import Optional
11
- import logging
11
+ from .structured_logging import get_logger
12
12
 
13
- logger = logging.getLogger(__name__)
13
+ logger = get_logger(__name__)
14
14
 
15
15
 
16
16
  def fix_json(text: str) -> Optional[str]:
@@ -11,4 +11,4 @@ including when the package is installed from PyPI where pyproject.toml is not av
11
11
 
12
12
  # Package version - update this when releasing new versions
13
13
  # This must be manually synchronized with the version in pyproject.toml
14
- __version__ = "2.5.3"
14
+ __version__ = "2.6.0"