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.
- abstractcore/apps/app_config_utils.py +19 -0
- abstractcore/apps/summarizer.py +85 -56
- abstractcore/architectures/detection.py +15 -4
- abstractcore/assets/architecture_formats.json +1 -1
- abstractcore/assets/model_capabilities.json +420 -11
- abstractcore/core/interface.py +2 -0
- abstractcore/core/session.py +4 -0
- abstractcore/embeddings/manager.py +54 -16
- abstractcore/media/__init__.py +116 -148
- abstractcore/media/auto_handler.py +363 -0
- abstractcore/media/base.py +456 -0
- abstractcore/media/capabilities.py +335 -0
- abstractcore/media/types.py +300 -0
- abstractcore/media/vision_fallback.py +260 -0
- abstractcore/providers/anthropic_provider.py +18 -1
- abstractcore/providers/base.py +187 -0
- abstractcore/providers/huggingface_provider.py +111 -12
- abstractcore/providers/lmstudio_provider.py +88 -5
- abstractcore/providers/mlx_provider.py +33 -1
- abstractcore/providers/ollama_provider.py +37 -3
- abstractcore/providers/openai_provider.py +18 -1
- abstractcore/server/app.py +1390 -104
- abstractcore/tools/common_tools.py +12 -8
- abstractcore/utils/__init__.py +9 -5
- abstractcore/utils/cli.py +199 -17
- abstractcore/utils/message_preprocessor.py +182 -0
- abstractcore/utils/structured_logging.py +117 -16
- abstractcore/utils/version.py +1 -1
- {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/METADATA +214 -20
- {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/RECORD +34 -27
- {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/entry_points.txt +1 -0
- {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/WHEEL +0 -0
- {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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)
|
abstractcore/providers/base.py
CHANGED
|
@@ -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:
|