abstractcore 2.4.2__py3-none-any.whl → 2.4.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.
Files changed (34) hide show
  1. abstractcore/apps/app_config_utils.py +19 -0
  2. abstractcore/apps/summarizer.py +85 -56
  3. abstractcore/architectures/detection.py +15 -4
  4. abstractcore/assets/architecture_formats.json +1 -1
  5. abstractcore/assets/model_capabilities.json +420 -11
  6. abstractcore/core/interface.py +2 -0
  7. abstractcore/core/session.py +4 -0
  8. abstractcore/embeddings/manager.py +54 -16
  9. abstractcore/media/__init__.py +116 -148
  10. abstractcore/media/auto_handler.py +363 -0
  11. abstractcore/media/base.py +456 -0
  12. abstractcore/media/capabilities.py +335 -0
  13. abstractcore/media/types.py +300 -0
  14. abstractcore/media/vision_fallback.py +260 -0
  15. abstractcore/providers/anthropic_provider.py +18 -1
  16. abstractcore/providers/base.py +187 -0
  17. abstractcore/providers/huggingface_provider.py +111 -12
  18. abstractcore/providers/lmstudio_provider.py +88 -5
  19. abstractcore/providers/mlx_provider.py +33 -1
  20. abstractcore/providers/ollama_provider.py +37 -3
  21. abstractcore/providers/openai_provider.py +18 -1
  22. abstractcore/server/app.py +1390 -104
  23. abstractcore/tools/common_tools.py +12 -8
  24. abstractcore/utils/__init__.py +9 -5
  25. abstractcore/utils/cli.py +199 -17
  26. abstractcore/utils/message_preprocessor.py +182 -0
  27. abstractcore/utils/structured_logging.py +117 -16
  28. abstractcore/utils/version.py +1 -1
  29. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/METADATA +214 -20
  30. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/RECORD +34 -27
  31. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/entry_points.txt +1 -0
  32. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/WHEEL +0 -0
  33. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/licenses/LICENSE +0 -0
  34. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,260 @@
1
+ """
2
+ Vision Fallback System for Text-Only Models
3
+
4
+ Implements two-stage pipeline: vision model → description → text-only model
5
+ Uses unified AbstractCore configuration system.
6
+ """
7
+
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class VisionNotConfiguredError(Exception):
16
+ """Raised when vision fallback is requested but not configured."""
17
+ pass
18
+
19
+
20
+ class VisionFallbackHandler:
21
+ """
22
+ Handles vision fallback for text-only models using two-stage pipeline.
23
+
24
+ When a text-only model receives an image:
25
+ 1. Uses configured vision model to generate description
26
+ 2. Provides description to text-only model for processing
27
+
28
+ Uses the unified AbstractCore configuration system.
29
+ """
30
+
31
+ def __init__(self, config_manager=None):
32
+ """Initialize with configuration manager."""
33
+ if config_manager is None:
34
+ from abstractcore.config import get_config_manager
35
+ self.config_manager = get_config_manager()
36
+ else:
37
+ self.config_manager = config_manager
38
+
39
+ @property
40
+ def vision_config(self):
41
+ """Get vision configuration from unified config system."""
42
+ return self.config_manager.config.vision
43
+
44
+ def create_description(self, image_path: str, user_prompt: str = None) -> str:
45
+ """
46
+ Generate description using configured vision model.
47
+
48
+ Args:
49
+ image_path: Path to the image file
50
+ user_prompt: Original user prompt for context
51
+
52
+ Returns:
53
+ Description string to be used by text-only model
54
+
55
+ Raises:
56
+ VisionNotConfiguredError: When vision fallback is not configured
57
+ """
58
+ if self.vision_config.strategy == "disabled":
59
+ raise VisionNotConfiguredError("Vision fallback is disabled")
60
+
61
+ if not self._has_vision_capability():
62
+ raise VisionNotConfiguredError("No vision capability configured")
63
+
64
+ try:
65
+ return self._generate_with_fallback(image_path)
66
+ except Exception as e:
67
+ logger.debug(f"Vision fallback failed: {e}")
68
+ raise VisionNotConfiguredError(f"Vision fallback generation failed: {e}")
69
+
70
+ def _has_vision_capability(self) -> bool:
71
+ """Check if any vision capability is configured."""
72
+ return (
73
+ (self.vision_config.caption_provider is not None and
74
+ self.vision_config.caption_model is not None) or
75
+ len(self.vision_config.fallback_chain) > 0 or
76
+ self._has_local_models()
77
+ )
78
+
79
+ def _has_local_models(self) -> bool:
80
+ """Check if any local vision models are available."""
81
+ models_dir = Path(self.vision_config.local_models_path).expanduser()
82
+ return models_dir.exists() and any(models_dir.iterdir())
83
+
84
+ def _generate_with_fallback(self, image_path: str) -> str:
85
+ """Try vision models in fallback chain order."""
86
+ # Try primary provider first
87
+ if self.vision_config.caption_provider and self.vision_config.caption_model:
88
+ try:
89
+ description = self._generate_description(
90
+ self.vision_config.caption_provider,
91
+ self.vision_config.caption_model,
92
+ image_path
93
+ )
94
+ return description
95
+ except Exception as e:
96
+ logger.debug(f"Primary vision provider failed: {e}")
97
+
98
+ # Try fallback chain
99
+ for provider_config in self.vision_config.fallback_chain:
100
+ try:
101
+ description = self._generate_description(
102
+ provider_config["provider"],
103
+ provider_config["model"],
104
+ image_path
105
+ )
106
+ return description
107
+ except Exception as e:
108
+ logger.debug(f"Vision provider {provider_config} failed: {e}")
109
+ continue
110
+
111
+ # Try local models
112
+ if self._has_local_models():
113
+ try:
114
+ description = self._generate_local_description(image_path)
115
+ return description
116
+ except Exception as e:
117
+ logger.debug(f"Local vision model failed: {e}")
118
+
119
+ raise Exception("All vision fallback providers failed")
120
+
121
+ def _generate_description(self, provider: str, model: str, image_path: str) -> str:
122
+ """Generate description using specified provider and model."""
123
+ try:
124
+ # Import here to avoid circular imports
125
+ from abstractcore import create_llm
126
+
127
+ vision_llm = create_llm(provider, model=model)
128
+ response = vision_llm.generate(
129
+ "Provide a detailed description of this image in 3-4 sentences. Be precise about specific landmarks, buildings, objects, and details. If you recognize specific places or things, name them accurately. Describe naturally without phrases like 'this image shows'.",
130
+ media=[image_path]
131
+ )
132
+ return response.content.strip()
133
+ except Exception as e:
134
+ logger.debug(f"Failed to generate description with {provider}/{model}: {e}")
135
+ raise
136
+
137
+ def _generate_local_description(self, image_path: str) -> str:
138
+ """Generate description using local vision model."""
139
+ try:
140
+ models_dir = Path(self.vision_config.local_models_path).expanduser()
141
+
142
+ # Look for downloaded vision models
143
+ for model_dir in models_dir.iterdir():
144
+ if model_dir.is_dir() and ("caption" in model_dir.name.lower() or "blip" in model_dir.name.lower() or "vit" in model_dir.name.lower() or "git" in model_dir.name.lower()):
145
+ try:
146
+ # Check if download is complete
147
+ if not (model_dir / "download_complete.txt").exists():
148
+ logger.debug(f"Model {model_dir.name} download incomplete")
149
+ continue
150
+
151
+ description = self._use_local_model(model_dir, image_path)
152
+ if description:
153
+ return description
154
+
155
+ except Exception as e:
156
+ logger.debug(f"Local model {model_dir} failed: {e}")
157
+ continue
158
+
159
+ raise Exception("No working local models found")
160
+ except ImportError:
161
+ raise Exception("transformers library not available for local models")
162
+
163
+ def _use_local_model(self, model_dir: Path, image_path: str) -> str:
164
+ """Use a specific local model to generate description."""
165
+ from PIL import Image
166
+
167
+ model_name = model_dir.name
168
+
169
+ if "blip" in model_name:
170
+ from transformers import BlipProcessor, BlipForConditionalGeneration
171
+
172
+ # Load BLIP model and processor
173
+ processor = BlipProcessor.from_pretrained(model_dir / "processor", use_fast=False)
174
+ model = BlipForConditionalGeneration.from_pretrained(model_dir / "model")
175
+
176
+ # Process image
177
+ image = Image.open(image_path).convert('RGB')
178
+ inputs = processor(image, return_tensors="pt")
179
+
180
+ # Generate description
181
+ out = model.generate(**inputs, max_length=50, num_beams=5)
182
+ description = processor.decode(out[0], skip_special_tokens=True)
183
+ return description
184
+
185
+ elif "vit-gpt2" in model_name:
186
+ from transformers import VisionEncoderDecoderModel, ViTImageProcessor, AutoTokenizer
187
+
188
+ # Load ViT-GPT2 components
189
+ model = VisionEncoderDecoderModel.from_pretrained(model_dir / "model")
190
+ feature_extractor = ViTImageProcessor.from_pretrained(model_dir / "feature_extractor")
191
+ tokenizer = AutoTokenizer.from_pretrained(model_dir / "tokenizer")
192
+
193
+ # Process image
194
+ image = Image.open(image_path).convert('RGB')
195
+ pixel_values = feature_extractor(images=image, return_tensors="pt").pixel_values
196
+
197
+ # Generate description
198
+ output_ids = model.generate(pixel_values, max_length=50, num_beams=4)
199
+ description = tokenizer.decode(output_ids[0], skip_special_tokens=True)
200
+ return description
201
+
202
+ elif "git" in model_name:
203
+ from transformers import GitProcessor, GitForCausalLM
204
+
205
+ # Load GIT model and processor
206
+ processor = GitProcessor.from_pretrained(model_dir / "processor")
207
+ model = GitForCausalLM.from_pretrained(model_dir / "model")
208
+
209
+ # Process image
210
+ image = Image.open(image_path).convert('RGB')
211
+ inputs = processor(images=image, return_tensors="pt")
212
+
213
+ # Generate description
214
+ generated_ids = model.generate(pixel_values=inputs.pixel_values, max_length=50)
215
+ description = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
216
+ return description
217
+
218
+ else:
219
+ # Try generic image-to-text pipeline
220
+ from transformers import pipeline
221
+ captioner = pipeline("image-to-text", model=str(model_dir))
222
+ result = captioner(image_path)
223
+ if result and len(result) > 0:
224
+ return result[0]["generated_text"]
225
+
226
+ return None
227
+
228
+ def _show_setup_instructions(self) -> str:
229
+ """Return helpful setup instructions for users."""
230
+ return """⚠️ Vision capability not configured for text-only models.
231
+
232
+ To enable image analysis with text-only models:
233
+ 1. Download local model: abstractcore --download-vision-model
234
+ 2. Use existing model: abstractcore --set-vision-caption qwen2.5vl:7b
235
+ 3. Use cloud API: abstractcore --set-vision-provider openai --model gpt-4o
236
+ 4. Interactive setup: abstractcore --configure
237
+
238
+ Current status: abstractcore --status"""
239
+
240
+ def get_status(self) -> Dict[str, Any]:
241
+ """Get current vision configuration status using unified config."""
242
+ return self.config_manager.get_status()["vision"]
243
+
244
+ def is_enabled(self) -> bool:
245
+ """Check if vision fallback is enabled and configured."""
246
+ return (self.vision_config.strategy == "two_stage" and
247
+ self._has_vision_capability())
248
+
249
+
250
+ # Convenience functions for easy integration
251
+ def has_vision_capability() -> bool:
252
+ """Check if vision fallback is configured and enabled."""
253
+ handler = VisionFallbackHandler()
254
+ return handler.is_enabled()
255
+
256
+
257
+ def create_image_description(image_path: str, user_prompt: str = None) -> str:
258
+ """Create image description for text-only models."""
259
+ handler = VisionFallbackHandler()
260
+ return handler.create_description(image_path, user_prompt)
@@ -61,6 +61,7 @@ class AnthropicProvider(BaseProvider):
61
61
  messages: Optional[List[Dict[str, str]]] = None,
62
62
  system_prompt: Optional[str] = None,
63
63
  tools: Optional[List[Dict[str, Any]]] = None,
64
+ media: Optional[List['MediaContent']] = None,
64
65
  stream: bool = False,
65
66
  response_model: Optional[Type[BaseModel]] = None,
66
67
  **kwargs) -> Union[GenerateResponse, Iterator[GenerateResponse]]:
@@ -89,7 +90,23 @@ class AnthropicProvider(BaseProvider):
89
90
 
90
91
  # Add current prompt as user message
91
92
  if prompt and prompt not in [msg.get("content") for msg in (messages or [])]:
92
- api_messages.append({"role": "user", "content": prompt})
93
+ # Handle multimodal message with media content
94
+ if media:
95
+ try:
96
+ from ..media.handlers import AnthropicMediaHandler
97
+ media_handler = AnthropicMediaHandler(self.model_capabilities)
98
+
99
+ # Create multimodal message combining text and media
100
+ multimodal_message = media_handler.create_multimodal_message(prompt, media)
101
+ api_messages.append(multimodal_message)
102
+ except ImportError:
103
+ self.logger.warning("Media processing not available. Install with: pip install abstractcore[media]")
104
+ api_messages.append({"role": "user", "content": prompt})
105
+ except Exception as e:
106
+ self.logger.warning(f"Failed to process media content: {e}")
107
+ api_messages.append({"role": "user", "content": prompt})
108
+ else:
109
+ api_messages.append({"role": "user", "content": prompt})
93
110
 
94
111
  # Prepare API call parameters using unified system
95
112
  generation_kwargs = self._prepare_generation_kwargs(**kwargs)
@@ -204,6 +204,7 @@ class BaseProvider(AbstractCoreInterface, ABC):
204
204
  messages: Optional[List[Dict[str, str]]] = None,
205
205
  system_prompt: Optional[str] = None,
206
206
  tools: Optional[List] = None, # Accept both ToolDefinition and Dict
207
+ media: Optional[List[Union[str, Dict[str, Any], 'MediaContent']]] = None, # Media files
207
208
  stream: bool = False,
208
209
  response_model: Optional[Type[BaseModel]] = None,
209
210
  retry_strategy=None, # Custom retry strategy for structured output
@@ -215,6 +216,12 @@ class BaseProvider(AbstractCoreInterface, ABC):
215
216
  Providers should override _generate_internal instead of generate.
216
217
 
217
218
  Args:
219
+ prompt: The input prompt
220
+ messages: Optional conversation history
221
+ system_prompt: Optional system prompt
222
+ tools: Optional list of available tools
223
+ media: Optional list of media files (file paths, MediaContent objects, or dicts)
224
+ stream: Whether to stream the response
218
225
  response_model: Optional Pydantic model for structured output
219
226
  retry_strategy: Optional retry strategy for structured output validation
220
227
  tool_call_tags: Optional tool call tag format for rewriting
@@ -235,6 +242,7 @@ class BaseProvider(AbstractCoreInterface, ABC):
235
242
  messages=messages,
236
243
  system_prompt=system_prompt,
237
244
  tools=tools,
245
+ media=media,
238
246
  response_model=response_model,
239
247
  retry_strategy=retry_strategy,
240
248
  tool_call_tags=tool_call_tags,
@@ -253,10 +261,16 @@ class BaseProvider(AbstractCoreInterface, ABC):
253
261
  messages=messages,
254
262
  system_prompt=system_prompt,
255
263
  tools=None, # No tools in this path
264
+ media=media,
256
265
  stream=stream,
257
266
  **kwargs
258
267
  )
259
268
 
269
+ # Process media content if provided
270
+ processed_media = None
271
+ if media:
272
+ processed_media = self._process_media_content(media)
273
+
260
274
  # Convert tools to ToolDefinition objects first (outside retry loop)
261
275
  converted_tools = None
262
276
  if tools:
@@ -308,6 +322,7 @@ class BaseProvider(AbstractCoreInterface, ABC):
308
322
  messages=messages,
309
323
  system_prompt=system_prompt,
310
324
  tools=converted_tools,
325
+ media=processed_media,
311
326
  stream=stream,
312
327
  execute_tools=should_execute_tools,
313
328
  tool_call_tags=tool_call_tags,
@@ -391,6 +406,7 @@ class BaseProvider(AbstractCoreInterface, ABC):
391
406
  messages: Optional[List[Dict[str, str]]] = None,
392
407
  system_prompt: Optional[str] = None,
393
408
  tools: Optional[List[Dict[str, Any]]] = None,
409
+ media: Optional[List['MediaContent']] = None,
394
410
  stream: bool = False,
395
411
  response_model: Optional[Type[BaseModel]] = None,
396
412
  execute_tools: Optional[bool] = None,
@@ -400,8 +416,15 @@ class BaseProvider(AbstractCoreInterface, ABC):
400
416
  This is called by generate_with_telemetry.
401
417
 
402
418
  Args:
419
+ prompt: The input prompt
420
+ messages: Optional conversation history
421
+ system_prompt: Optional system prompt
422
+ tools: Optional list of available tools
423
+ media: Optional list of processed MediaContent objects
424
+ stream: Whether to stream the response
403
425
  response_model: Optional Pydantic model for structured output
404
426
  execute_tools: Whether to execute tools automatically (True) or let agent handle execution (False)
427
+ **kwargs: Additional provider-specific parameters
405
428
  """
406
429
  raise NotImplementedError("Subclasses must implement _generate_internal")
407
430
 
@@ -757,6 +780,73 @@ class BaseProvider(AbstractCoreInterface, ABC):
757
780
  """Rough estimation of token count for given text"""
758
781
  return super().estimate_tokens(text)
759
782
 
783
+ def _process_media_content(self, media: List[Union[str, Dict[str, Any], 'MediaContent']]) -> List['MediaContent']:
784
+ """
785
+ Process media content from various input formats into standardized MediaContent objects.
786
+
787
+ Args:
788
+ media: List of media inputs (file paths, MediaContent objects, or dicts)
789
+
790
+ Returns:
791
+ List of processed MediaContent objects
792
+
793
+ Raises:
794
+ ImportError: If media processing dependencies are not available
795
+ ValueError: If media input format is invalid
796
+ """
797
+ if not media:
798
+ return []
799
+
800
+ try:
801
+ # Import media handler components
802
+ from ..media import AutoMediaHandler
803
+ from ..media.types import MediaContent
804
+ except ImportError as e:
805
+ raise ImportError(
806
+ f"Media processing requires additional dependencies. "
807
+ f"Install with: pip install abstractcore[media]. Error: {e}"
808
+ )
809
+
810
+ processed_media = []
811
+
812
+ for i, media_item in enumerate(media):
813
+ try:
814
+ if isinstance(media_item, str):
815
+ # File path - process with auto media handler
816
+ handler = AutoMediaHandler()
817
+ result = handler.process_file(media_item)
818
+ if result.success:
819
+ processed_media.append(result.media_content)
820
+ else:
821
+ self.logger.warning(f"Failed to process media file {media_item}: {result.error_message}")
822
+ continue
823
+
824
+ elif hasattr(media_item, 'media_type'):
825
+ # Already a MediaContent object
826
+ processed_media.append(media_item)
827
+
828
+ elif isinstance(media_item, dict):
829
+ # Dictionary format - convert to MediaContent
830
+ try:
831
+ media_content = MediaContent.from_dict(media_item)
832
+ processed_media.append(media_content)
833
+ except Exception as e:
834
+ self.logger.warning(f"Failed to convert media dict at index {i}: {e}")
835
+ continue
836
+
837
+ else:
838
+ self.logger.warning(f"Unsupported media type at index {i}: {type(media_item)}")
839
+ continue
840
+
841
+ except Exception as e:
842
+ self.logger.warning(f"Failed to process media item at index {i}: {e}")
843
+ continue
844
+
845
+ if not processed_media and media:
846
+ self.logger.warning("No media items were successfully processed")
847
+
848
+ return processed_media
849
+
760
850
  @abstractmethod
761
851
  def list_available_models(self, **kwargs) -> List[str]:
762
852
  """
@@ -777,6 +867,103 @@ class BaseProvider(AbstractCoreInterface, ABC):
777
867
  """
778
868
  pass
779
869
 
870
+ def health(self, timeout: Optional[float] = 5.0) -> Dict[str, Any]:
871
+ """
872
+ Check provider health and connectivity.
873
+
874
+ This method tests if the provider is online and accessible by attempting
875
+ to list available models. A successful model listing indicates the provider
876
+ is healthy and ready to serve requests.
877
+
878
+ Args:
879
+ timeout: Maximum time in seconds to wait for health check (default: 5.0).
880
+ None means unlimited timeout (not recommended for health checks).
881
+
882
+ Returns:
883
+ Dict with health status information:
884
+ {
885
+ "status": bool, # True if provider is healthy/online
886
+ "provider": str, # Provider class name (e.g., "OpenAIProvider")
887
+ "models": List[str] | None, # Available models if online, None if offline
888
+ "model_count": int, # Number of models available (0 if offline)
889
+ "error": str | None, # Error message if offline, None if healthy
890
+ "latency_ms": float # Time taken for health check in milliseconds
891
+ }
892
+
893
+ Example:
894
+ >>> provider = OllamaProvider(model="llama2")
895
+ >>> health = provider.health(timeout=3.0)
896
+ >>> if health["status"]:
897
+ >>> print(f"Healthy! {health['model_count']} models available")
898
+ >>> else:
899
+ >>> print(f"Offline: {health['error']}")
900
+
901
+ Note:
902
+ - This method never raises exceptions; errors are captured in the response
903
+ - Uses list_available_models() as the connectivity test
904
+ - Providers can override this method for custom health check logic
905
+ """
906
+ import time as time_module
907
+
908
+ start_time = time_module.time()
909
+ provider_name = self.__class__.__name__
910
+
911
+ try:
912
+ # Attempt to list models as connectivity test
913
+ # Store original timeout if provider has HTTP client
914
+ original_timeout = None
915
+ timeout_changed = False
916
+
917
+ if timeout is not None and hasattr(self, '_timeout'):
918
+ original_timeout = self._timeout
919
+ if original_timeout != timeout:
920
+ self.set_timeout(timeout)
921
+ timeout_changed = True
922
+
923
+ try:
924
+ models = self.list_available_models()
925
+
926
+ # Restore original timeout if changed
927
+ if timeout_changed and original_timeout is not None:
928
+ self.set_timeout(original_timeout)
929
+
930
+ latency_ms = (time_module.time() - start_time) * 1000
931
+
932
+ return {
933
+ "status": True,
934
+ "provider": provider_name,
935
+ "models": models,
936
+ "model_count": len(models) if models else 0,
937
+ "error": None,
938
+ "latency_ms": round(latency_ms, 2)
939
+ }
940
+
941
+ except Exception as e:
942
+ # Restore original timeout on error
943
+ if timeout_changed and original_timeout is not None:
944
+ try:
945
+ self.set_timeout(original_timeout)
946
+ except:
947
+ pass # Best effort restoration
948
+ raise # Re-raise to be caught by outer handler
949
+
950
+ except Exception as e:
951
+ latency_ms = (time_module.time() - start_time) * 1000
952
+
953
+ # Extract meaningful error message
954
+ error_message = str(e)
955
+ if not error_message:
956
+ error_message = f"{type(e).__name__} occurred during health check"
957
+
958
+ return {
959
+ "status": False,
960
+ "provider": provider_name,
961
+ "models": None,
962
+ "model_count": 0,
963
+ "error": error_message,
964
+ "latency_ms": round(latency_ms, 2)
965
+ }
966
+
780
967
  def _needs_tag_rewriting(self, tool_call_tags) -> bool:
781
968
  """Check if tag rewriting is needed (tags are non-standard)"""
782
969
  try: