abstractcore 2.5.2__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 (66) hide show
  1. abstractcore/__init__.py +19 -1
  2. abstractcore/architectures/detection.py +252 -6
  3. abstractcore/assets/architecture_formats.json +14 -1
  4. abstractcore/assets/model_capabilities.json +533 -10
  5. abstractcore/compression/__init__.py +29 -0
  6. abstractcore/compression/analytics.py +420 -0
  7. abstractcore/compression/cache.py +250 -0
  8. abstractcore/compression/config.py +279 -0
  9. abstractcore/compression/exceptions.py +30 -0
  10. abstractcore/compression/glyph_processor.py +381 -0
  11. abstractcore/compression/optimizer.py +388 -0
  12. abstractcore/compression/orchestrator.py +380 -0
  13. abstractcore/compression/pil_text_renderer.py +818 -0
  14. abstractcore/compression/quality.py +226 -0
  15. abstractcore/compression/text_formatter.py +666 -0
  16. abstractcore/compression/vision_compressor.py +371 -0
  17. abstractcore/config/main.py +64 -0
  18. abstractcore/config/manager.py +100 -5
  19. abstractcore/core/retry.py +2 -2
  20. abstractcore/core/session.py +193 -7
  21. abstractcore/download.py +253 -0
  22. abstractcore/embeddings/manager.py +2 -2
  23. abstractcore/events/__init__.py +113 -2
  24. abstractcore/exceptions/__init__.py +49 -2
  25. abstractcore/media/auto_handler.py +312 -18
  26. abstractcore/media/handlers/local_handler.py +14 -2
  27. abstractcore/media/handlers/openai_handler.py +62 -3
  28. abstractcore/media/processors/__init__.py +11 -1
  29. abstractcore/media/processors/direct_pdf_processor.py +210 -0
  30. abstractcore/media/processors/glyph_pdf_processor.py +227 -0
  31. abstractcore/media/processors/image_processor.py +7 -1
  32. abstractcore/media/processors/office_processor.py +2 -2
  33. abstractcore/media/processors/text_processor.py +18 -3
  34. abstractcore/media/types.py +164 -7
  35. abstractcore/media/utils/image_scaler.py +2 -2
  36. abstractcore/media/vision_fallback.py +2 -2
  37. abstractcore/providers/__init__.py +18 -0
  38. abstractcore/providers/anthropic_provider.py +228 -8
  39. abstractcore/providers/base.py +378 -11
  40. abstractcore/providers/huggingface_provider.py +563 -23
  41. abstractcore/providers/lmstudio_provider.py +284 -4
  42. abstractcore/providers/mlx_provider.py +27 -2
  43. abstractcore/providers/model_capabilities.py +352 -0
  44. abstractcore/providers/ollama_provider.py +282 -6
  45. abstractcore/providers/openai_provider.py +286 -8
  46. abstractcore/providers/registry.py +85 -13
  47. abstractcore/providers/streaming.py +2 -2
  48. abstractcore/server/app.py +91 -81
  49. abstractcore/tools/common_tools.py +2 -2
  50. abstractcore/tools/handler.py +2 -2
  51. abstractcore/tools/parser.py +2 -2
  52. abstractcore/tools/registry.py +2 -2
  53. abstractcore/tools/syntax_rewriter.py +2 -2
  54. abstractcore/tools/tag_rewriter.py +3 -3
  55. abstractcore/utils/__init__.py +4 -1
  56. abstractcore/utils/self_fixes.py +2 -2
  57. abstractcore/utils/trace_export.py +287 -0
  58. abstractcore/utils/version.py +1 -1
  59. abstractcore/utils/vlm_token_calculator.py +655 -0
  60. {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/METADATA +207 -8
  61. abstractcore-2.6.0.dist-info/RECORD +108 -0
  62. abstractcore-2.5.2.dist-info/RECORD +0 -90
  63. {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/WHEEL +0 -0
  64. {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/entry_points.txt +0 -0
  65. {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/licenses/LICENSE +0 -0
  66. {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/top_level.txt +0 -0
@@ -5,7 +5,7 @@ LM Studio provider implementation (OpenAI-compatible API).
5
5
  import httpx
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
@@ -15,7 +15,7 @@ except ImportError:
15
15
  BaseModel = None
16
16
  from .base import BaseProvider
17
17
  from ..core.types import GenerateResponse
18
- from ..exceptions import ProviderAPIError, ModelNotFoundError, format_model_error
18
+ from ..exceptions import ProviderAPIError, ModelNotFoundError, format_model_error, format_provider_error
19
19
  from ..tools import UniversalToolHandler, execute_tools
20
20
  from ..events import EventType
21
21
 
@@ -47,9 +47,21 @@ class LMStudioProvider(BaseProvider):
47
47
  except Exception:
48
48
  raise RuntimeError(f"Failed to create HTTP client for LMStudio: {e}")
49
49
 
50
+ self._async_client = None # Lazy-loaded async client
51
+
50
52
  # Validate model exists in LMStudio
51
53
  self._validate_model()
52
54
 
55
+ @property
56
+ def async_client(self):
57
+ """Lazy-load async HTTP client for native async operations."""
58
+ if self._async_client is None:
59
+ timeout_value = getattr(self, '_timeout', None)
60
+ if timeout_value is not None and timeout_value <= 0:
61
+ timeout_value = None
62
+ self._async_client = httpx.AsyncClient(timeout=timeout_value)
63
+ return self._async_client
64
+
53
65
  def _validate_model(self):
54
66
  """Validate that the model exists in LMStudio"""
55
67
  try:
@@ -87,6 +99,17 @@ class LMStudioProvider(BaseProvider):
87
99
  if hasattr(self, 'client') and self.client is not None:
88
100
  self.client.close()
89
101
 
102
+ # Close async client if it was created
103
+ if self._async_client is not None:
104
+ import asyncio
105
+ try:
106
+ loop = asyncio.get_running_loop()
107
+ loop.create_task(self._async_client.aclose())
108
+ except RuntimeError:
109
+ # No running loop
110
+ import asyncio
111
+ asyncio.run(self._async_client.aclose())
112
+
90
113
  except Exception as e:
91
114
  # Log but don't raise - unload should be best-effort
92
115
  if hasattr(self, 'logger'):
@@ -202,6 +225,15 @@ class LMStudioProvider(BaseProvider):
202
225
  "max_tokens": max_output_tokens, # LMStudio uses max_tokens for output tokens
203
226
  "top_p": kwargs.get("top_p", 0.9),
204
227
  }
228
+
229
+ # Add additional generation parameters if provided (OpenAI-compatible)
230
+ if "frequency_penalty" in kwargs:
231
+ payload["frequency_penalty"] = kwargs["frequency_penalty"]
232
+ if "presence_penalty" in kwargs:
233
+ payload["presence_penalty"] = kwargs["presence_penalty"]
234
+ if "repetition_penalty" in kwargs:
235
+ # Some models support repetition_penalty directly
236
+ payload["repetition_penalty"] = kwargs["repetition_penalty"]
205
237
 
206
238
  # Add seed if provided (LMStudio supports seed via OpenAI-compatible API)
207
239
  seed_value = kwargs.get("seed", self.seed)
@@ -350,6 +382,227 @@ class LMStudioProvider(BaseProvider):
350
382
  finish_reason="error"
351
383
  )
352
384
 
385
+ async def _agenerate_internal(self,
386
+ prompt: str,
387
+ messages: Optional[List[Dict[str, str]]] = None,
388
+ system_prompt: Optional[str] = None,
389
+ tools: Optional[List[Dict[str, Any]]] = None,
390
+ media: Optional[List['MediaContent']] = None,
391
+ stream: bool = False,
392
+ response_model: Optional[Type[BaseModel]] = None,
393
+ execute_tools: Optional[bool] = None,
394
+ tool_call_tags: Optional[str] = None,
395
+ **kwargs) -> Union[GenerateResponse, AsyncIterator[GenerateResponse]]:
396
+ """Native async implementation using httpx.AsyncClient - 3-10x faster for batch operations."""
397
+
398
+ # Build messages for chat completions with tool support (same logic as sync)
399
+ chat_messages = []
400
+
401
+ # Add tools to system prompt if provided
402
+ enhanced_system_prompt = system_prompt
403
+ if tools and self.tool_handler.supports_prompted:
404
+ tool_prompt = self.tool_handler.format_tools_prompt(tools)
405
+ if enhanced_system_prompt:
406
+ enhanced_system_prompt += f"\n\n{tool_prompt}"
407
+ else:
408
+ enhanced_system_prompt = tool_prompt
409
+
410
+ # Add system message if provided
411
+ if enhanced_system_prompt:
412
+ chat_messages.append({
413
+ "role": "system",
414
+ "content": enhanced_system_prompt
415
+ })
416
+
417
+ # Add conversation history
418
+ if messages:
419
+ chat_messages.extend(messages)
420
+
421
+ # Handle media content
422
+ if media:
423
+ user_message_text = prompt.strip() if prompt else ""
424
+ if not user_message_text and chat_messages:
425
+ for msg in reversed(chat_messages):
426
+ if msg.get("role") == "user" and msg.get("content"):
427
+ user_message_text = msg["content"]
428
+ break
429
+ try:
430
+ processed_media = self._process_media_content(media)
431
+ media_handler = self._get_media_handler_for_model(self.model)
432
+ multimodal_message = media_handler.create_multimodal_message(user_message_text, processed_media)
433
+
434
+ if isinstance(multimodal_message, str):
435
+ if chat_messages and chat_messages[-1].get("role") == "user":
436
+ chat_messages[-1]["content"] = multimodal_message
437
+ else:
438
+ chat_messages.append({"role": "user", "content": multimodal_message})
439
+ else:
440
+ if chat_messages and chat_messages[-1].get("role") == "user":
441
+ chat_messages[-1] = multimodal_message
442
+ else:
443
+ chat_messages.append(multimodal_message)
444
+ except ImportError:
445
+ self.logger.warning("Media processing not available. Install with: pip install abstractcore[media]")
446
+ if user_message_text:
447
+ chat_messages.append({"role": "user", "content": user_message_text})
448
+ except Exception as e:
449
+ self.logger.warning(f"Failed to process media content: {e}")
450
+ if user_message_text:
451
+ chat_messages.append({"role": "user", "content": user_message_text})
452
+
453
+ # Add prompt as separate message if provided
454
+ elif prompt and prompt.strip():
455
+ chat_messages.append({"role": "user", "content": prompt})
456
+
457
+ # Build request payload
458
+ generation_kwargs = self._prepare_generation_kwargs(**kwargs)
459
+ max_output_tokens = self._get_provider_max_tokens_param(generation_kwargs)
460
+
461
+ payload = {
462
+ "model": self.model,
463
+ "messages": chat_messages,
464
+ "stream": stream,
465
+ "temperature": kwargs.get("temperature", self.temperature),
466
+ "max_tokens": max_output_tokens,
467
+ "top_p": kwargs.get("top_p", 0.9),
468
+ }
469
+
470
+ # Add additional parameters
471
+ if "frequency_penalty" in kwargs:
472
+ payload["frequency_penalty"] = kwargs["frequency_penalty"]
473
+ if "presence_penalty" in kwargs:
474
+ payload["presence_penalty"] = kwargs["presence_penalty"]
475
+ if "repetition_penalty" in kwargs:
476
+ payload["repetition_penalty"] = kwargs["repetition_penalty"]
477
+
478
+ # Add seed if provided
479
+ seed_value = kwargs.get("seed", self.seed)
480
+ if seed_value is not None:
481
+ payload["seed"] = seed_value
482
+
483
+ # Add structured output support
484
+ if response_model and PYDANTIC_AVAILABLE:
485
+ json_schema = response_model.model_json_schema()
486
+ payload["response_format"] = {
487
+ "type": "json_schema",
488
+ "json_schema": {
489
+ "name": response_model.__name__,
490
+ "schema": json_schema
491
+ }
492
+ }
493
+
494
+ if stream:
495
+ return self._async_stream_generate(payload)
496
+ else:
497
+ response = await self._async_single_generate(payload)
498
+
499
+ # Execute tools if enabled
500
+ if self.execute_tools and tools and self.tool_handler.supports_prompted and response.content:
501
+ response = self._handle_prompted_tool_execution(response, tools, execute_tools)
502
+
503
+ return response
504
+
505
+ async def _async_single_generate(self, payload: Dict[str, Any]) -> GenerateResponse:
506
+ """Native async single response generation."""
507
+ try:
508
+ # Track generation time
509
+ start_time = time.time()
510
+ response = await self.async_client.post(
511
+ f"{self.base_url}/chat/completions",
512
+ json=payload,
513
+ headers={"Content-Type": "application/json"}
514
+ )
515
+ response.raise_for_status()
516
+ gen_time = round((time.time() - start_time) * 1000, 1)
517
+
518
+ result = response.json()
519
+
520
+ # Extract response from OpenAI format
521
+ if "choices" in result and len(result["choices"]) > 0:
522
+ choice = result["choices"][0]
523
+ content = choice.get("message", {}).get("content", "")
524
+ finish_reason = choice.get("finish_reason", "stop")
525
+ else:
526
+ content = "No response generated"
527
+ finish_reason = "error"
528
+
529
+ # Extract usage info
530
+ usage = result.get("usage", {})
531
+
532
+ return GenerateResponse(
533
+ content=content,
534
+ model=self.model,
535
+ finish_reason=finish_reason,
536
+ raw_response=result,
537
+ usage={
538
+ "input_tokens": usage.get("prompt_tokens", 0),
539
+ "output_tokens": usage.get("completion_tokens", 0),
540
+ "total_tokens": usage.get("total_tokens", 0),
541
+ "prompt_tokens": usage.get("prompt_tokens", 0),
542
+ "completion_tokens": usage.get("completion_tokens", 0)
543
+ },
544
+ gen_time=gen_time
545
+ )
546
+
547
+ except Exception as e:
548
+ error_str = str(e).lower()
549
+ if ('404' in error_str or 'not found' in error_str or 'model' in error_str) and ('not found' in error_str):
550
+ try:
551
+ available_models = self.list_available_models(base_url=self.base_url)
552
+ error_message = format_model_error("LMStudio", self.model, available_models)
553
+ raise ModelNotFoundError(error_message)
554
+ except Exception:
555
+ raise ModelNotFoundError(f"Model '{self.model}' not found in LMStudio")
556
+ else:
557
+ raise ProviderAPIError(f"LMStudio API error: {str(e)}")
558
+
559
+ async def _async_stream_generate(self, payload: Dict[str, Any]) -> AsyncIterator[GenerateResponse]:
560
+ """Native async streaming response generation."""
561
+ try:
562
+ async with self.async_client.stream(
563
+ "POST",
564
+ f"{self.base_url}/chat/completions",
565
+ json=payload,
566
+ headers={"Content-Type": "application/json"}
567
+ ) as response:
568
+ response.raise_for_status()
569
+
570
+ async for line in response.aiter_lines():
571
+ if line:
572
+ line = line.strip()
573
+
574
+ if line.startswith("data: "):
575
+ data = line[6:] # Remove "data: " prefix
576
+
577
+ if data == "[DONE]":
578
+ break
579
+
580
+ try:
581
+ chunk = json.loads(data)
582
+
583
+ if "choices" in chunk and len(chunk["choices"]) > 0:
584
+ choice = chunk["choices"][0]
585
+ delta = choice.get("delta", {})
586
+ content = delta.get("content", "")
587
+ finish_reason = choice.get("finish_reason")
588
+
589
+ yield GenerateResponse(
590
+ content=content,
591
+ model=self.model,
592
+ finish_reason=finish_reason,
593
+ raw_response=chunk
594
+ )
595
+
596
+ except json.JSONDecodeError:
597
+ continue
598
+
599
+ except Exception as e:
600
+ yield GenerateResponse(
601
+ content=f"Error: {str(e)}",
602
+ model=self.model,
603
+ finish_reason="error"
604
+ )
605
+
353
606
  def get_capabilities(self) -> List[str]:
354
607
  """Get LM Studio capabilities"""
355
608
  return ["streaming", "chat", "tools"]
@@ -426,8 +679,21 @@ class LMStudioProvider(BaseProvider):
426
679
  return handler
427
680
 
428
681
  def list_available_models(self, **kwargs) -> List[str]:
429
- """List available models from LMStudio server."""
682
+ """
683
+ List available models from LMStudio server.
684
+
685
+ Args:
686
+ **kwargs: Optional parameters including:
687
+ - base_url: LMStudio server URL
688
+ - input_capabilities: List of ModelInputCapability enums to filter by input capability
689
+ - output_capabilities: List of ModelOutputCapability enums to filter by output capability
690
+
691
+ Returns:
692
+ List of model names, optionally filtered by capabilities
693
+ """
430
694
  try:
695
+ from .model_capabilities import filter_models_by_capabilities
696
+
431
697
  # Use provided base_url or fall back to instance base_url
432
698
  base_url = kwargs.get('base_url', self.base_url)
433
699
 
@@ -435,7 +701,21 @@ class LMStudioProvider(BaseProvider):
435
701
  if response.status_code == 200:
436
702
  data = response.json()
437
703
  models = [model["id"] for model in data.get("data", [])]
438
- return sorted(models)
704
+ models = sorted(models)
705
+
706
+ # Apply new capability filtering if provided
707
+ input_capabilities = kwargs.get('input_capabilities')
708
+ output_capabilities = kwargs.get('output_capabilities')
709
+
710
+ if input_capabilities or output_capabilities:
711
+ models = filter_models_by_capabilities(
712
+ models,
713
+ input_capabilities=input_capabilities,
714
+ output_capabilities=output_capabilities
715
+ )
716
+
717
+
718
+ return models
439
719
  else:
440
720
  self.logger.warning(f"LMStudio API returned status {response.status_code}")
441
721
  return []
@@ -494,8 +494,19 @@ class MLXProvider(BaseProvider):
494
494
 
495
495
  @classmethod
496
496
  def list_available_models(cls, **kwargs) -> List[str]:
497
- """List available MLX models from HuggingFace cache."""
497
+ """
498
+ List available MLX models from HuggingFace cache.
499
+
500
+ Args:
501
+ **kwargs: Optional parameters including:
502
+ - input_capabilities: List of ModelInputCapability enums to filter by input capability
503
+ - output_capabilities: List of ModelOutputCapability enums to filter by output capability
504
+
505
+ Returns:
506
+ List of model names, optionally filtered by capabilities
507
+ """
498
508
  from pathlib import Path
509
+ from .model_capabilities import filter_models_by_capabilities
499
510
 
500
511
  try:
501
512
  hf_cache = Path.home() / ".cache" / "huggingface" / "hub"
@@ -513,7 +524,21 @@ class MLXProvider(BaseProvider):
513
524
  if "mlx" in model_name.lower():
514
525
  models.append(model_name)
515
526
 
516
- return sorted(models)
527
+ models = sorted(models)
528
+
529
+ # Apply new capability filtering if provided
530
+ input_capabilities = kwargs.get('input_capabilities')
531
+ output_capabilities = kwargs.get('output_capabilities')
532
+
533
+ if input_capabilities or output_capabilities:
534
+ models = filter_models_by_capabilities(
535
+ models,
536
+ input_capabilities=input_capabilities,
537
+ output_capabilities=output_capabilities
538
+ )
539
+
540
+
541
+ return models
517
542
 
518
543
  except Exception:
519
544
  return []