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
abstractcore/__init__.py CHANGED
@@ -49,6 +49,9 @@ _has_processing = True
49
49
  # Tools module (core functionality)
50
50
  from .tools import tool
51
51
 
52
+ # Download module (core functionality)
53
+ from .download import download_model, DownloadProgress, DownloadStatus
54
+
52
55
  # Compression module (optional import)
53
56
  try:
54
57
  from .compression import GlyphConfig, CompressionOrchestrator
@@ -67,7 +70,10 @@ __all__ = [
67
70
  'ModelNotFoundError',
68
71
  'ProviderAPIError',
69
72
  'AuthenticationError',
70
- 'tool'
73
+ 'tool',
74
+ 'download_model',
75
+ 'DownloadProgress',
76
+ 'DownloadStatus',
71
77
  ]
72
78
 
73
79
  if _has_embeddings:
@@ -9,9 +9,9 @@ import json
9
9
  import os
10
10
  from typing import Dict, Any, Optional, List
11
11
  from pathlib import Path
12
- import logging
12
+ from ..utils.structured_logging import get_logger
13
13
 
14
- logger = logging.getLogger(__name__)
14
+ logger = get_logger(__name__)
15
15
 
16
16
  # Cache for loaded JSON data
17
17
  _architecture_formats: Optional[Dict[str, Any]] = None
@@ -7,4 +7,27 @@ Provides configuration management and command-line interface for AbstractCore.
7
7
  from .vision_config import handle_vision_commands, add_vision_arguments
8
8
  from .manager import get_config_manager
9
9
 
10
- __all__ = ['handle_vision_commands', 'add_vision_arguments', 'get_config_manager']
10
+
11
+ def configure_provider(provider: str, **kwargs) -> None:
12
+ """Configure runtime settings for a provider."""
13
+ get_config_manager().configure_provider(provider, **kwargs)
14
+
15
+
16
+ def get_provider_config(provider: str) -> dict:
17
+ """Get runtime configuration for a provider."""
18
+ return get_config_manager().get_provider_config(provider)
19
+
20
+
21
+ def clear_provider_config(provider: str = None) -> None:
22
+ """Clear runtime provider configuration."""
23
+ get_config_manager().clear_provider_config(provider)
24
+
25
+
26
+ __all__ = [
27
+ 'handle_vision_commands',
28
+ 'add_vision_arguments',
29
+ 'get_config_manager',
30
+ 'configure_provider',
31
+ 'get_provider_config',
32
+ 'clear_provider_config'
33
+ ]
@@ -136,6 +136,7 @@ class ConfigurationManager:
136
136
  self.config_dir = Path.home() / ".abstractcore" / "config"
137
137
  self.config_file = self.config_dir / "abstractcore.json"
138
138
  self.config = self._load_config()
139
+ self._provider_config: Dict[str, Dict[str, Any]] = {} # Runtime config (not persisted)
139
140
 
140
141
  def _load_config(self) -> AbstractCoreConfig:
141
142
  """Load configuration from file or create default."""
@@ -437,6 +438,52 @@ class ConfigurationManager:
437
438
  """Check if local_files_only should be forced for transformers."""
438
439
  return self.config.offline.force_local_files_only
439
440
 
441
+ def configure_provider(self, provider: str, **kwargs) -> None:
442
+ """
443
+ Configure runtime settings for a provider.
444
+
445
+ Args:
446
+ provider: Provider name ('ollama', 'lmstudio', 'openai', 'anthropic')
447
+ **kwargs: Configuration options (base_url, timeout, etc.)
448
+
449
+ Example:
450
+ configure_provider('ollama', base_url='http://192.168.1.100:11434')
451
+ """
452
+ provider = provider.lower()
453
+ if provider not in self._provider_config:
454
+ self._provider_config[provider] = {}
455
+
456
+ for key, value in kwargs.items():
457
+ if value is None:
458
+ # Remove config (revert to env var / default)
459
+ self._provider_config[provider].pop(key, None)
460
+ else:
461
+ self._provider_config[provider][key] = value
462
+
463
+ def get_provider_config(self, provider: str) -> Dict[str, Any]:
464
+ """
465
+ Get runtime configuration for a provider.
466
+
467
+ Args:
468
+ provider: Provider name
469
+
470
+ Returns:
471
+ Dict with configured settings, or empty dict if no config
472
+ """
473
+ return self._provider_config.get(provider.lower(), {}).copy()
474
+
475
+ def clear_provider_config(self, provider: Optional[str] = None) -> None:
476
+ """
477
+ Clear runtime provider configuration.
478
+
479
+ Args:
480
+ provider: Provider name, or None to clear all
481
+ """
482
+ if provider is None:
483
+ self._provider_config.clear()
484
+ else:
485
+ self._provider_config.pop(provider.lower(), None)
486
+
440
487
 
441
488
  # Global instance
442
489
  _config_manager = None
@@ -8,13 +8,13 @@ and production LLM system requirements.
8
8
 
9
9
  import time
10
10
  import random
11
- import logging
12
11
  from typing import Type, Optional, Set, Dict, Any
13
12
  from dataclasses import dataclass
14
13
  from datetime import datetime, timedelta
15
14
  from enum import Enum
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 RetryableErrorType(Enum):
@@ -3,11 +3,12 @@ BasicSession for conversation tracking.
3
3
  Target: <500 lines maximum.
4
4
  """
5
5
 
6
- from typing import List, Optional, Dict, Any, Union, Iterator, Callable
6
+ from typing import List, Optional, Dict, Any, Union, Iterator, AsyncIterator, Callable
7
7
  from datetime import datetime
8
8
  from pathlib import Path
9
9
  import json
10
10
  import uuid
11
+ import asyncio
11
12
  from collections.abc import Generator
12
13
 
13
14
  from .interface import AbstractCoreInterface
@@ -273,6 +274,136 @@ class BasicSession:
273
274
  if collected_content:
274
275
  self.add_message('assistant', collected_content)
275
276
 
277
+ async def agenerate(self,
278
+ prompt: str,
279
+ name: Optional[str] = None,
280
+ location: Optional[str] = None,
281
+ **kwargs) -> Union[GenerateResponse, AsyncIterator[GenerateResponse]]:
282
+ """
283
+ Async generation with conversation history.
284
+
285
+ Args:
286
+ prompt: User message
287
+ name: Optional speaker name
288
+ location: Optional location context
289
+ **kwargs: Generation parameters (stream, temperature, etc.)
290
+
291
+ Returns:
292
+ GenerateResponse or AsyncIterator for streaming
293
+
294
+ Example:
295
+ # Async chat interaction
296
+ response = await session.agenerate('What is Python?')
297
+
298
+ # Async streaming
299
+ async for chunk in await session.agenerate('Tell me a story', stream=True):
300
+ print(chunk.content, end='')
301
+ """
302
+ if not self.provider:
303
+ raise ValueError("No provider configured")
304
+
305
+ # Check for auto-compaction before generating
306
+ if self.auto_compact and self.should_compact(self.auto_compact_threshold):
307
+ print(f"🗜️ Auto-compacting session (tokens: {self.get_token_estimate()} > {self.auto_compact_threshold})")
308
+ compacted = self.compact(reason="auto_threshold")
309
+ # Replace current session with compacted version
310
+ self._replace_with_compacted(compacted)
311
+
312
+ # Pre-processing (fast, sync is fine)
313
+ self.add_message('user', prompt, name=name, location=location)
314
+
315
+ # Format messages for provider (exclude the current user message since provider will add it)
316
+ messages = self._format_messages_for_provider_excluding_current()
317
+
318
+ # Use session tools if not provided in kwargs
319
+ if 'tools' not in kwargs and self.tools:
320
+ kwargs['tools'] = self.tools
321
+
322
+ # Pass session tool_call_tags if available and not overridden in kwargs
323
+ if hasattr(self, 'tool_call_tags') and self.tool_call_tags is not None and 'tool_call_tags' not in kwargs:
324
+ kwargs['tool_call_tags'] = self.tool_call_tags
325
+
326
+ # Extract media parameter explicitly
327
+ media = kwargs.pop('media', None)
328
+
329
+ # Add session-level parameters if not overridden in kwargs
330
+ if 'temperature' not in kwargs and self.temperature is not None:
331
+ kwargs['temperature'] = self.temperature
332
+ if 'seed' not in kwargs and self.seed is not None:
333
+ kwargs['seed'] = self.seed
334
+
335
+ # Add trace metadata if tracing is enabled
336
+ if self.enable_tracing:
337
+ if 'trace_metadata' not in kwargs:
338
+ kwargs['trace_metadata'] = {}
339
+ kwargs['trace_metadata'].update({
340
+ 'session_id': self.id,
341
+ 'step_type': kwargs.get('step_type', 'chat'),
342
+ 'attempt_number': kwargs.get('attempt_number', 1)
343
+ })
344
+
345
+ # Check if streaming
346
+ stream = kwargs.get('stream', False)
347
+
348
+ if stream:
349
+ # Return async streaming wrapper that adds assistant message after
350
+ return self._async_session_stream(prompt, messages, media, **kwargs)
351
+ else:
352
+ # Async generation
353
+ response = await self.provider.agenerate(
354
+ prompt=prompt,
355
+ messages=messages,
356
+ system_prompt=self.system_prompt,
357
+ media=media,
358
+ **kwargs
359
+ )
360
+
361
+ # Post-processing (fast, sync is fine)
362
+ if hasattr(response, 'content') and response.content:
363
+ self.add_message('assistant', response.content)
364
+
365
+ # Capture trace if enabled and available
366
+ if self.enable_tracing and hasattr(self.provider, 'get_traces'):
367
+ if hasattr(response, 'metadata') and response.metadata and 'trace_id' in response.metadata:
368
+ trace = self.provider.get_traces(response.metadata['trace_id'])
369
+ if trace:
370
+ self.interaction_traces.append(trace)
371
+
372
+ return response
373
+
374
+ async def _async_session_stream(self,
375
+ prompt: str,
376
+ messages: List[Dict[str, str]],
377
+ media: Optional[List],
378
+ **kwargs) -> AsyncIterator[GenerateResponse]:
379
+ """Async streaming with session history management."""
380
+ collected_content = ""
381
+
382
+ # Remove 'stream' from kwargs since we're explicitly setting it
383
+ kwargs_copy = {k: v for k, v in kwargs.items() if k != 'stream'}
384
+
385
+ # CRITICAL: Await first to get async generator, then iterate
386
+ stream_gen = await self.provider.agenerate(
387
+ prompt=prompt,
388
+ messages=messages,
389
+ system_prompt=self.system_prompt,
390
+ media=media,
391
+ stream=True,
392
+ **kwargs_copy
393
+ )
394
+
395
+ async for chunk in stream_gen:
396
+ # Yield the chunk for the caller
397
+ yield chunk
398
+
399
+ # Collect content for history
400
+ if hasattr(chunk, 'content') and chunk.content:
401
+ collected_content += chunk.content
402
+
403
+ # After streaming completes, add assistant message
404
+ if collected_content:
405
+ self.add_message('assistant', collected_content)
406
+
276
407
  def _format_messages_for_provider(self) -> List[Dict[str, str]]:
277
408
  """Format messages for provider API"""
278
409
  return [
@@ -0,0 +1,253 @@
1
+ """
2
+ Model download API with async progress reporting.
3
+
4
+ Provides a provider-agnostic interface for downloading models from Ollama,
5
+ HuggingFace Hub, and MLX with streaming progress updates.
6
+ """
7
+
8
+ import json
9
+ import asyncio
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from typing import AsyncIterator, Optional
13
+
14
+ import httpx
15
+
16
+
17
+ class DownloadStatus(Enum):
18
+ """Download progress status."""
19
+
20
+ STARTING = "starting"
21
+ DOWNLOADING = "downloading"
22
+ VERIFYING = "verifying"
23
+ COMPLETE = "complete"
24
+ ERROR = "error"
25
+
26
+
27
+ @dataclass
28
+ class DownloadProgress:
29
+ """Progress information for model download."""
30
+
31
+ status: DownloadStatus
32
+ message: str
33
+ percent: Optional[float] = None # 0-100
34
+ downloaded_bytes: Optional[int] = None
35
+ total_bytes: Optional[int] = None
36
+
37
+
38
+ async def download_model(
39
+ provider: str,
40
+ model: str,
41
+ token: Optional[str] = None,
42
+ base_url: Optional[str] = None,
43
+ ) -> AsyncIterator[DownloadProgress]:
44
+ """
45
+ Download a model with async progress reporting.
46
+
47
+ This function provides a unified interface for downloading models across
48
+ different providers. Progress updates are yielded as DownloadProgress
49
+ dataclasses that include status, message, and optional progress percentage.
50
+
51
+ Args:
52
+ provider: Provider name ("ollama", "huggingface", "mlx")
53
+ model: Model identifier:
54
+ - Ollama: "llama3:8b", "gemma3:1b", etc.
55
+ - HuggingFace/MLX: "meta-llama/Llama-2-7b", "mlx-community/Qwen3-4B-4bit", etc.
56
+ token: Optional auth token (for HuggingFace gated models)
57
+ base_url: Optional custom base URL (for Ollama, default: http://localhost:11434)
58
+
59
+ Yields:
60
+ DownloadProgress: Progress updates with status, message, and optional metrics
61
+
62
+ Raises:
63
+ ValueError: If provider doesn't support downloads (OpenAI, Anthropic, LMStudio)
64
+ httpx.HTTPStatusError: If Ollama server returns error
65
+ Exception: Various exceptions from HuggingFace Hub (RepositoryNotFoundError, etc.)
66
+
67
+ Examples:
68
+ Download Ollama model:
69
+ >>> async for progress in download_model("ollama", "gemma3:1b"):
70
+ ... print(f"{progress.status.value}: {progress.message}")
71
+ ... if progress.percent:
72
+ ... print(f" Progress: {progress.percent:.1f}%")
73
+
74
+ Download HuggingFace model with token:
75
+ >>> async for progress in download_model(
76
+ ... "huggingface",
77
+ ... "meta-llama/Llama-2-7b",
78
+ ... token="hf_..."
79
+ ... ):
80
+ ... print(f"{progress.message}")
81
+ """
82
+ provider_lower = provider.lower()
83
+
84
+ if provider_lower == "ollama":
85
+ async for progress in _download_ollama(model, base_url):
86
+ yield progress
87
+ elif provider_lower in ("huggingface", "mlx"):
88
+ async for progress in _download_huggingface(model, token):
89
+ yield progress
90
+ else:
91
+ raise ValueError(
92
+ f"Provider '{provider}' does not support model downloads. "
93
+ f"Supported providers: ollama, huggingface, mlx. "
94
+ f"Note: OpenAI and Anthropic are cloud-only; LMStudio has no download API."
95
+ )
96
+
97
+
98
+ async def _download_ollama(
99
+ model: str,
100
+ base_url: Optional[str] = None,
101
+ ) -> AsyncIterator[DownloadProgress]:
102
+ """
103
+ Download model from Ollama using /api/pull endpoint.
104
+
105
+ Args:
106
+ model: Ollama model name (e.g., "llama3:8b", "gemma3:1b")
107
+ base_url: Ollama server URL (default: http://localhost:11434)
108
+
109
+ Yields:
110
+ DownloadProgress with status updates from Ollama streaming response
111
+ """
112
+ url = (base_url or "http://localhost:11434").rstrip("/")
113
+
114
+ yield DownloadProgress(
115
+ status=DownloadStatus.STARTING, message=f"Pulling {model} from Ollama..."
116
+ )
117
+
118
+ try:
119
+ async with httpx.AsyncClient(timeout=None) as client:
120
+ async with client.stream(
121
+ "POST",
122
+ f"{url}/api/pull",
123
+ json={"name": model, "stream": True},
124
+ ) as response:
125
+ response.raise_for_status()
126
+
127
+ async for line in response.aiter_lines():
128
+ if not line:
129
+ continue
130
+
131
+ try:
132
+ data = json.loads(line)
133
+ except json.JSONDecodeError:
134
+ continue
135
+
136
+ status_msg = data.get("status", "")
137
+
138
+ # Parse progress from Ollama response
139
+ # Format: {"status": "downloading...", "total": 123, "completed": 45}
140
+ if "total" in data and "completed" in data:
141
+ total = data["total"]
142
+ completed = data["completed"]
143
+ percent = (completed / total * 100) if total > 0 else 0
144
+
145
+ yield DownloadProgress(
146
+ status=DownloadStatus.DOWNLOADING,
147
+ message=status_msg,
148
+ percent=percent,
149
+ downloaded_bytes=completed,
150
+ total_bytes=total,
151
+ )
152
+ elif status_msg == "success":
153
+ yield DownloadProgress(
154
+ status=DownloadStatus.COMPLETE,
155
+ message=f"Successfully pulled {model}",
156
+ percent=100.0,
157
+ )
158
+ elif "verifying" in status_msg.lower():
159
+ yield DownloadProgress(
160
+ status=DownloadStatus.VERIFYING,
161
+ message=status_msg,
162
+ )
163
+ else:
164
+ # Other status messages (pulling manifest, etc.)
165
+ yield DownloadProgress(
166
+ status=DownloadStatus.DOWNLOADING,
167
+ message=status_msg,
168
+ )
169
+
170
+ except httpx.HTTPStatusError as e:
171
+ yield DownloadProgress(
172
+ status=DownloadStatus.ERROR,
173
+ message=f"Ollama server error: {e.response.status_code} - {e.response.text}",
174
+ )
175
+ except httpx.ConnectError:
176
+ yield DownloadProgress(
177
+ status=DownloadStatus.ERROR,
178
+ message=f"Cannot connect to Ollama server at {url}. Is Ollama running?",
179
+ )
180
+ except Exception as e:
181
+ yield DownloadProgress(
182
+ status=DownloadStatus.ERROR,
183
+ message=f"Download failed: {str(e)}",
184
+ )
185
+
186
+
187
+ async def _download_huggingface(
188
+ model: str,
189
+ token: Optional[str] = None,
190
+ ) -> AsyncIterator[DownloadProgress]:
191
+ """
192
+ Download model from HuggingFace Hub.
193
+
194
+ Args:
195
+ model: HuggingFace model identifier (e.g., "meta-llama/Llama-2-7b")
196
+ token: Optional HuggingFace token (required for gated models)
197
+
198
+ Yields:
199
+ DownloadProgress with status updates
200
+ """
201
+ yield DownloadProgress(
202
+ status=DownloadStatus.STARTING,
203
+ message=f"Downloading {model} from HuggingFace Hub...",
204
+ )
205
+
206
+ try:
207
+ # Import here to make huggingface_hub optional
208
+ from huggingface_hub import snapshot_download
209
+ from huggingface_hub.utils import GatedRepoError, RepositoryNotFoundError
210
+ except ImportError:
211
+ yield DownloadProgress(
212
+ status=DownloadStatus.ERROR,
213
+ message=(
214
+ "huggingface_hub is not installed. "
215
+ "Install with: pip install abstractcore[huggingface]"
216
+ ),
217
+ )
218
+ return
219
+
220
+ try:
221
+ # Run blocking download in thread
222
+ # Note: snapshot_download doesn't have built-in async progress callbacks
223
+ # We provide start and completion messages
224
+ await asyncio.to_thread(
225
+ snapshot_download,
226
+ repo_id=model,
227
+ token=token,
228
+ )
229
+
230
+ yield DownloadProgress(
231
+ status=DownloadStatus.COMPLETE,
232
+ message=f"Successfully downloaded {model}",
233
+ percent=100.0,
234
+ )
235
+
236
+ except RepositoryNotFoundError:
237
+ yield DownloadProgress(
238
+ status=DownloadStatus.ERROR,
239
+ message=f"Model '{model}' not found on HuggingFace Hub",
240
+ )
241
+ except GatedRepoError:
242
+ yield DownloadProgress(
243
+ status=DownloadStatus.ERROR,
244
+ message=(
245
+ f"Model '{model}' requires authentication. "
246
+ f"Provide a HuggingFace token via the 'token' parameter."
247
+ ),
248
+ )
249
+ except Exception as e:
250
+ yield DownloadProgress(
251
+ status=DownloadStatus.ERROR,
252
+ message=f"Download failed: {str(e)}",
253
+ )
@@ -7,7 +7,6 @@ Production-ready embedding generation with SOTA models and efficient serving.
7
7
 
8
8
  import hashlib
9
9
  import pickle
10
- import logging
11
10
  import atexit
12
11
  import sys
13
12
  import builtins
@@ -33,8 +32,9 @@ except ImportError:
33
32
  emit_global = None
34
33
 
35
34
  from .models import EmbeddingBackend, get_model_config, list_available_models, get_default_model
35
+ from ..utils.structured_logging import get_logger
36
36
 
37
- logger = logging.getLogger(__name__)
37
+ logger = get_logger(__name__)
38
38
 
39
39
 
40
40
  @contextmanager