kailash 0.4.0__py3-none-any.whl → 0.4.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 (32) hide show
  1. kailash/__init__.py +3 -4
  2. kailash/middleware/__init__.py +4 -2
  3. kailash/middleware/auth/__init__.py +55 -12
  4. kailash/middleware/auth/exceptions.py +80 -0
  5. kailash/middleware/auth/jwt_auth.py +265 -123
  6. kailash/middleware/auth/models.py +137 -0
  7. kailash/middleware/auth/utils.py +257 -0
  8. kailash/middleware/communication/api_gateway.py +49 -7
  9. kailash/middleware/core/agent_ui.py +108 -1
  10. kailash/middleware/mcp/enhanced_server.py +2 -2
  11. kailash/nodes/__init__.py +2 -0
  12. kailash/nodes/admin/__init__.py +9 -2
  13. kailash/nodes/admin/audit_log.py +1 -1
  14. kailash/nodes/admin/security_event.py +7 -3
  15. kailash/nodes/ai/ai_providers.py +247 -40
  16. kailash/nodes/ai/llm_agent.py +29 -3
  17. kailash/nodes/ai/vision_utils.py +148 -0
  18. kailash/nodes/alerts/__init__.py +26 -0
  19. kailash/nodes/alerts/base.py +234 -0
  20. kailash/nodes/alerts/discord.py +499 -0
  21. kailash/nodes/code/python.py +18 -0
  22. kailash/nodes/data/streaming.py +8 -8
  23. kailash/nodes/security/audit_log.py +48 -36
  24. kailash/nodes/security/security_event.py +73 -72
  25. kailash/security.py +1 -1
  26. {kailash-0.4.0.dist-info → kailash-0.4.2.dist-info}/METADATA +4 -1
  27. {kailash-0.4.0.dist-info → kailash-0.4.2.dist-info}/RECORD +31 -25
  28. kailash/middleware/auth/kailash_jwt_auth.py +0 -616
  29. {kailash-0.4.0.dist-info → kailash-0.4.2.dist-info}/WHEEL +0 -0
  30. {kailash-0.4.0.dist-info → kailash-0.4.2.dist-info}/entry_points.txt +0 -0
  31. {kailash-0.4.0.dist-info → kailash-0.4.2.dist-info}/licenses/LICENSE +0 -0
  32. {kailash-0.4.0.dist-info → kailash-0.4.2.dist-info}/top_level.txt +0 -0
@@ -24,7 +24,11 @@ from enum import Enum
24
24
  from typing import Any, Dict, List, Optional, Tuple
25
25
 
26
26
  from kailash.access_control import UserContext
27
- from kailash.nodes.admin.audit_log import AuditEventType, AuditLogNode, AuditSeverity
27
+ from kailash.nodes.admin.audit_log import (
28
+ AuditEventType,
29
+ AuditSeverity,
30
+ EnterpriseAuditLogNode,
31
+ )
28
32
  from kailash.nodes.base import Node, NodeParameter, register_node
29
33
  from kailash.nodes.data import AsyncSQLDatabaseNode
30
34
  from kailash.sdk_exceptions import NodeExecutionError, NodeValidationError
@@ -169,7 +173,7 @@ class SecurityIncident:
169
173
 
170
174
 
171
175
  @register_node()
172
- class SecurityEventNode(Node):
176
+ class EnterpriseSecurityEventNode(Node):
173
177
  """Enterprise security event monitoring and incident response node.
174
178
 
175
179
  This node provides comprehensive security event processing including:
@@ -412,7 +416,7 @@ class SecurityEventNode(Node):
412
416
  self._db_node = AsyncSQLDatabaseNode(name="security_event_db", **db_config)
413
417
 
414
418
  # Initialize audit logging node
415
- self._audit_node = AuditLogNode(database_config=db_config)
419
+ self._audit_node = EnterpriseAuditLogNode(database_config=db_config)
416
420
 
417
421
  def _create_event(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
418
422
  """Create a new security event with risk scoring."""
@@ -8,7 +8,11 @@ separation between LLM and embedding capabilities.
8
8
 
9
9
  import hashlib
10
10
  from abc import ABC, abstractmethod
11
- from typing import Any
11
+ from typing import Any, Dict, List, Union
12
+
13
+ # Type definitions for flexible message content
14
+ MessageContent = Union[str, List[Dict[str, Any]]]
15
+ Message = Dict[str, Union[str, MessageContent]]
12
16
 
13
17
 
14
18
  class BaseAIProvider(ABC):
@@ -205,12 +209,14 @@ class LLMProvider(BaseAIProvider):
205
209
  self._capabilities["chat"] = True
206
210
 
207
211
  @abstractmethod
208
- def chat(self, messages: list[dict[str, str]], **kwargs) -> dict[str, Any]:
212
+ def chat(self, messages: List[Message], **kwargs) -> dict[str, Any]:
209
213
  """
210
214
  Generate a chat completion using the provider's LLM.
211
215
 
212
216
  Args:
213
217
  messages: Conversation messages in OpenAI format
218
+ Can be simple: [{"role": "user", "content": "text"}]
219
+ Or complex: [{"role": "user", "content": [{"type": "text", "text": "..."}, {"type": "image", "path": "..."}]}]
214
220
  **kwargs: Provider-specific parameters
215
221
 
216
222
  Returns:
@@ -391,7 +397,7 @@ class OllamaProvider(UnifiedAIProvider):
391
397
 
392
398
  return self._available
393
399
 
394
- def chat(self, messages: list[dict[str, str]], **kwargs) -> dict[str, Any]:
400
+ def chat(self, messages: List[Message], **kwargs) -> dict[str, Any]:
395
401
  """Generate a chat completion using Ollama.
396
402
 
397
403
  Args:
@@ -435,8 +441,50 @@ class OllamaProvider(UnifiedAIProvider):
435
441
  # Remove None values
436
442
  options = {k: v for k, v in options.items() if v is not None}
437
443
 
444
+ # Process messages for vision content
445
+ processed_messages = []
446
+
447
+ for msg in messages:
448
+ if isinstance(msg.get("content"), list):
449
+ # Complex content with potential images
450
+ text_parts = []
451
+ images = []
452
+
453
+ for item in msg["content"]:
454
+ if item["type"] == "text":
455
+ text_parts.append(item["text"])
456
+ elif item["type"] == "image":
457
+ # Lazy load vision utilities
458
+ from .vision_utils import encode_image
459
+
460
+ if "path" in item:
461
+ # For file paths, read the file directly
462
+ with open(item["path"], "rb") as f:
463
+ images.append(f.read())
464
+ else:
465
+ # For base64, decode it to bytes
466
+ import base64
467
+
468
+ base64_data = item.get("base64", "")
469
+ images.append(base64.b64decode(base64_data))
470
+
471
+ # Ollama expects images as part of the message
472
+ message_dict = {
473
+ "role": msg["role"],
474
+ "content": " ".join(text_parts),
475
+ }
476
+ if images:
477
+ message_dict["images"] = images
478
+
479
+ processed_messages.append(message_dict)
480
+ else:
481
+ # Simple string content (backward compatible)
482
+ processed_messages.append(msg)
483
+
438
484
  # Call Ollama
439
- response = ollama.chat(model=model, messages=messages, options=options)
485
+ response = ollama.chat(
486
+ model=model, messages=processed_messages, options=options
487
+ )
440
488
 
441
489
  # Format response to match standard structure
442
490
  return {
@@ -545,11 +593,18 @@ class OpenAIProvider(UnifiedAIProvider):
545
593
  - Install openai package: `pip install openai`
546
594
 
547
595
  Supported LLM models:
548
- - gpt-4-turbo (latest GPT-4 Turbo)
549
- - gpt-4 (standard GPT-4)
550
- - gpt-4-32k (32k context window)
551
- - gpt-3.5-turbo (latest GPT-3.5)
552
- - gpt-3.5-turbo-16k (16k context window)
596
+ - o4-mini (latest, vision support, recommended)
597
+ - o3 (reasoning model)
598
+
599
+ Note: This provider uses max_completion_tokens parameter compatible with
600
+ latest OpenAI models. Older models (gpt-4, gpt-3.5-turbo) are not supported.
601
+
602
+ Generation Config Parameters:
603
+ - max_completion_tokens (int): Maximum tokens to generate (recommended)
604
+ - max_tokens (int): Deprecated, use max_completion_tokens instead
605
+ - temperature (float): Sampling temperature (0-2)
606
+ - top_p (float): Nucleus sampling probability
607
+ - Other standard OpenAI parameters
553
608
 
554
609
  Supported embedding models:
555
610
  - text-embedding-3-large (3072 dimensions, configurable)
@@ -572,19 +627,22 @@ class OpenAIProvider(UnifiedAIProvider):
572
627
 
573
628
  return self._available
574
629
 
575
- def chat(self, messages: list[dict[str, str]], **kwargs) -> dict[str, Any]:
630
+ def chat(self, messages: List[Message], **kwargs) -> dict[str, Any]:
576
631
  """
577
632
  Generate a chat completion using OpenAI.
578
633
 
579
634
  Supported kwargs:
580
- - model (str): OpenAI model name (default: "gpt-4")
581
- - generation_config (dict): Generation parameters
635
+ - model (str): OpenAI model name (default: "o4-mini")
636
+ - generation_config (dict): Generation parameters including:
637
+ - max_completion_tokens (int): Max tokens to generate (recommended)
638
+ - max_tokens (int): Deprecated, use max_completion_tokens
639
+ - temperature, top_p, frequency_penalty, presence_penalty, etc.
582
640
  - tools (List[Dict]): Function/tool definitions for function calling
583
641
  """
584
642
  try:
585
643
  import openai
586
644
 
587
- model = kwargs.get("model", "gpt-4")
645
+ model = kwargs.get("model", "o4-mini")
588
646
  generation_config = kwargs.get("generation_config", {})
589
647
  tools = kwargs.get("tools", [])
590
648
 
@@ -592,13 +650,86 @@ class OpenAIProvider(UnifiedAIProvider):
592
650
  if self._client is None:
593
651
  self._client = openai.OpenAI()
594
652
 
653
+ # Process messages for vision content
654
+ processed_messages = []
655
+ for msg in messages:
656
+ if isinstance(msg.get("content"), list):
657
+ # Complex content with potential images
658
+ processed_content = []
659
+ for item in msg["content"]:
660
+ if item.get("type") == "text":
661
+ processed_content.append(
662
+ {"type": "text", "text": item.get("text", "")}
663
+ )
664
+ elif item.get("type") == "image":
665
+ # Lazy load vision utilities
666
+ from .vision_utils import (
667
+ encode_image,
668
+ get_media_type,
669
+ validate_image_size,
670
+ )
671
+
672
+ if "path" in item:
673
+ # Validate image size
674
+ is_valid, error_msg = validate_image_size(item["path"])
675
+ if not is_valid:
676
+ raise ValueError(
677
+ f"Image validation failed: {error_msg}"
678
+ )
679
+
680
+ base64_image = encode_image(item["path"])
681
+ media_type = get_media_type(item["path"])
682
+ elif "base64" in item:
683
+ base64_image = item["base64"]
684
+ media_type = item.get("media_type", "image/jpeg")
685
+ else:
686
+ raise ValueError(
687
+ "Image item must have either 'path' or 'base64' field"
688
+ )
689
+
690
+ processed_content.append(
691
+ {
692
+ "type": "image_url",
693
+ "image_url": {
694
+ "url": f"data:{media_type};base64,{base64_image}"
695
+ },
696
+ }
697
+ )
698
+
699
+ processed_messages.append(
700
+ {"role": msg.get("role", "user"), "content": processed_content}
701
+ )
702
+ else:
703
+ # Simple string content (backward compatible)
704
+ processed_messages.append(msg)
705
+
706
+ # Handle max tokens parameter - support both old and new names
707
+ max_completion = generation_config.get(
708
+ "max_completion_tokens"
709
+ ) or generation_config.get("max_tokens", 500)
710
+
711
+ # Show deprecation warning if using old parameter
712
+ # TODO: remove the max_tokens in the future.
713
+ if (
714
+ "max_tokens" in generation_config
715
+ and "max_completion_tokens" not in generation_config
716
+ ):
717
+ import warnings
718
+
719
+ warnings.warn(
720
+ "'max_tokens' is deprecated and will be removed in v0.5.0. "
721
+ "Please use 'max_completion_tokens' instead.",
722
+ DeprecationWarning,
723
+ stacklevel=3,
724
+ )
725
+
595
726
  # Prepare request
596
727
  request_params = {
597
728
  "model": model,
598
- "messages": messages,
599
- "temperature": generation_config.get("temperature", 0.7),
600
- "max_tokens": generation_config.get("max_tokens", 500),
601
- "top_p": generation_config.get("top_p", 0.9),
729
+ "messages": processed_messages,
730
+ "temperature": generation_config.get("temperature", 1.0),
731
+ "max_completion_tokens": max_completion, # Always use new parameter
732
+ "top_p": generation_config.get("top_p", 1.0),
602
733
  "frequency_penalty": generation_config.get("frequency_penalty"),
603
734
  "presence_penalty": generation_config.get("presence_penalty"),
604
735
  "stop": generation_config.get("stop"),
@@ -649,6 +780,15 @@ class OpenAIProvider(UnifiedAIProvider):
649
780
  raise RuntimeError(
650
781
  "OpenAI library not installed. Install with: pip install openai"
651
782
  )
783
+ except openai.BadRequestError as e:
784
+ # Provide helpful error message for unsupported models or parameters
785
+ if "max_tokens" in str(e):
786
+ raise RuntimeError(
787
+ "This OpenAI provider requires models that support max_completion_tokens. "
788
+ "Please use o4-mini, o3 "
789
+ "Older models like gpt-4o or gpt-3.5-turbo are not supported."
790
+ )
791
+ raise RuntimeError(f"OpenAI API error: {str(e)}")
652
792
  except Exception as e:
653
793
  raise RuntimeError(f"OpenAI error: {str(e)}")
654
794
 
@@ -772,7 +912,7 @@ class AnthropicProvider(LLMProvider):
772
912
 
773
913
  return self._available
774
914
 
775
- def chat(self, messages: list[dict[str, str]], **kwargs) -> dict[str, Any]:
915
+ def chat(self, messages: List[Message], **kwargs) -> dict[str, Any]:
776
916
  """Generate a chat completion using Anthropic."""
777
917
  try:
778
918
  import anthropic
@@ -790,22 +930,75 @@ class AnthropicProvider(LLMProvider):
790
930
 
791
931
  for msg in messages:
792
932
  if msg["role"] == "system":
793
- system_message = msg["content"]
933
+ # System messages are always text
934
+ system_message = (
935
+ msg["content"]
936
+ if isinstance(msg["content"], str)
937
+ else str(msg["content"])
938
+ )
794
939
  else:
795
- user_messages.append(msg)
796
-
797
- # Call Anthropic
798
- response = self._client.messages.create(
799
- model=model,
800
- messages=user_messages,
801
- system=system_message,
802
- max_tokens=generation_config.get("max_tokens", 500),
803
- temperature=generation_config.get("temperature", 0.7),
804
- top_p=generation_config.get("top_p"),
805
- top_k=generation_config.get("top_k"),
806
- stop_sequences=generation_config.get("stop_sequences"),
807
- metadata=generation_config.get("metadata"),
808
- )
940
+ # Process potentially complex content
941
+ if isinstance(msg.get("content"), list):
942
+ # Complex content with potential images
943
+ content_parts = []
944
+
945
+ for item in msg["content"]:
946
+ if item["type"] == "text":
947
+ content_parts.append(
948
+ {"type": "text", "text": item["text"]}
949
+ )
950
+ elif item["type"] == "image":
951
+ # Lazy load vision utilities
952
+ from .vision_utils import encode_image, get_media_type
953
+
954
+ if "path" in item:
955
+ base64_image = encode_image(item["path"])
956
+ media_type = get_media_type(item["path"])
957
+ else:
958
+ base64_image = item.get("base64", "")
959
+ media_type = item.get("media_type", "image/jpeg")
960
+
961
+ content_parts.append(
962
+ {
963
+ "type": "image",
964
+ "source": {
965
+ "type": "base64",
966
+ "media_type": media_type,
967
+ "data": base64_image,
968
+ },
969
+ }
970
+ )
971
+
972
+ user_messages.append(
973
+ {"role": msg["role"], "content": content_parts}
974
+ )
975
+ else:
976
+ # Simple string content (backward compatible)
977
+ user_messages.append(msg)
978
+
979
+ # Call Anthropic - build kwargs to avoid passing None values
980
+ create_kwargs = {
981
+ "model": model,
982
+ "messages": user_messages,
983
+ "max_tokens": generation_config.get("max_tokens", 500),
984
+ "temperature": generation_config.get("temperature", 0.7),
985
+ }
986
+
987
+ # Only add optional parameters if they have valid values
988
+ if system_message is not None:
989
+ create_kwargs["system"] = system_message
990
+ if generation_config.get("top_p") is not None:
991
+ create_kwargs["top_p"] = generation_config.get("top_p")
992
+ if generation_config.get("top_k") is not None:
993
+ create_kwargs["top_k"] = generation_config.get("top_k")
994
+ if generation_config.get("stop_sequences") is not None:
995
+ create_kwargs["stop_sequences"] = generation_config.get(
996
+ "stop_sequences"
997
+ )
998
+ if generation_config.get("metadata") is not None:
999
+ create_kwargs["metadata"] = generation_config.get("metadata")
1000
+
1001
+ response = self._client.messages.create(**create_kwargs)
809
1002
 
810
1003
  # Format response
811
1004
  return {
@@ -1232,16 +1425,33 @@ class MockProvider(UnifiedAIProvider):
1232
1425
  """Mock provider is always available."""
1233
1426
  return True
1234
1427
 
1235
- def chat(self, messages: list[dict[str, str]], **kwargs) -> dict[str, Any]:
1428
+ def chat(self, messages: List[Message], **kwargs) -> dict[str, Any]:
1236
1429
  """Generate mock LLM response."""
1237
1430
  last_user_message = ""
1431
+ has_images = False
1432
+
1238
1433
  for msg in reversed(messages):
1239
1434
  if msg.get("role") == "user":
1240
- last_user_message = msg.get("content", "")
1435
+ content = msg.get("content", "")
1436
+ # Handle complex content with images
1437
+ if isinstance(content, list):
1438
+ text_parts = []
1439
+ for item in content:
1440
+ if item.get("type") == "text":
1441
+ text_parts.append(item.get("text", ""))
1442
+ elif item.get("type") == "image":
1443
+ has_images = True
1444
+ last_user_message = " ".join(text_parts)
1445
+ else:
1446
+ last_user_message = content
1241
1447
  break
1242
1448
 
1243
1449
  # Generate contextual mock response
1244
- if "analyze" in last_user_message.lower():
1450
+ if has_images:
1451
+ response_content = (
1452
+ "I can see the image(s) you've provided. [Mock vision response]"
1453
+ )
1454
+ elif "analyze" in last_user_message.lower():
1245
1455
  response_content = "Based on the provided data and context, I can see several key patterns..."
1246
1456
  elif "create" in last_user_message.lower():
1247
1457
  response_content = "I'll help you create that. Based on the requirements..."
@@ -1259,10 +1469,7 @@ class MockProvider(UnifiedAIProvider):
1259
1469
  "tool_calls": [],
1260
1470
  "finish_reason": "stop",
1261
1471
  "usage": {
1262
- "prompt_tokens": len(
1263
- " ".join(msg.get("content", "") for msg in messages)
1264
- )
1265
- // 4,
1472
+ "prompt_tokens": 100, # Mock value
1266
1473
  "completion_tokens": len(response_content) // 4,
1267
1474
  "total_tokens": 0, # Will be calculated
1268
1475
  },
@@ -1412,13 +1412,28 @@ class LLMAgentNode(Node):
1412
1412
  ) -> dict[str, Any]:
1413
1413
  """Generate mock LLM response for testing."""
1414
1414
  last_user_message = ""
1415
+ has_images = False
1416
+
1415
1417
  for msg in reversed(messages):
1416
1418
  if msg.get("role") == "user":
1417
- last_user_message = msg.get("content", "")
1419
+ content = msg.get("content", "")
1420
+ # Handle complex content with images
1421
+ if isinstance(content, list):
1422
+ text_parts = []
1423
+ for item in content:
1424
+ if item.get("type") == "text":
1425
+ text_parts.append(item.get("text", ""))
1426
+ elif item.get("type") == "image":
1427
+ has_images = True
1428
+ last_user_message = " ".join(text_parts)
1429
+ else:
1430
+ last_user_message = content
1418
1431
  break
1419
1432
 
1420
1433
  # Generate contextual mock response
1421
- if "analyze" in last_user_message.lower():
1434
+ if has_images:
1435
+ response_content = "I can see the image(s) you've provided. Based on my analysis, [Mock vision response for testing]"
1436
+ elif "analyze" in last_user_message.lower():
1422
1437
  response_content = "Based on the provided data and context, I can see several key patterns: 1) Customer engagement has increased by 15% this quarter, 2) Product A shows the highest conversion rate, and 3) There are opportunities for improvement in the onboarding process."
1423
1438
  elif (
1424
1439
  "create" in last_user_message.lower()
@@ -1458,7 +1473,18 @@ class LLMAgentNode(Node):
1458
1473
  "finish_reason": "stop" if not tool_calls else "tool_calls",
1459
1474
  "usage": {
1460
1475
  "prompt_tokens": len(
1461
- " ".join(msg.get("content", "") for msg in messages)
1476
+ " ".join(
1477
+ (
1478
+ msg.get("content", "")
1479
+ if isinstance(msg.get("content"), str)
1480
+ else " ".join(
1481
+ item.get("text", "")
1482
+ for item in msg.get("content", [])
1483
+ if item.get("type") == "text"
1484
+ )
1485
+ )
1486
+ for msg in messages
1487
+ )
1462
1488
  )
1463
1489
  // 4,
1464
1490
  "completion_tokens": len(response_content) // 4,
@@ -0,0 +1,148 @@
1
+ """Vision utilities for AI providers - lazy loaded to avoid overhead."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Tuple
5
+
6
+
7
+ def encode_image(image_path: str) -> str:
8
+ """
9
+ Encode image file to base64 string.
10
+
11
+ Args:
12
+ image_path: Path to the image file
13
+
14
+ Returns:
15
+ Base64 encoded string of the image
16
+
17
+ Raises:
18
+ FileNotFoundError: If image file doesn't exist
19
+ IOError: If unable to read the image file
20
+ """
21
+ # Lazy import to avoid overhead when not using vision
22
+ import base64
23
+
24
+ image_path = Path(image_path).resolve()
25
+ if not image_path.exists():
26
+ raise FileNotFoundError(f"Image file not found: {image_path}")
27
+
28
+ try:
29
+ with open(image_path, "rb") as image_file:
30
+ return base64.b64encode(image_file.read()).decode("utf-8")
31
+ except Exception as e:
32
+ raise IOError(f"Failed to read image file: {e}")
33
+
34
+
35
+ def get_media_type(image_path: str) -> str:
36
+ """
37
+ Get media type from file extension.
38
+
39
+ Args:
40
+ image_path: Path to the image file
41
+
42
+ Returns:
43
+ Media type string (e.g., "image/jpeg")
44
+ """
45
+ ext = Path(image_path).suffix.lower()
46
+ media_types = {
47
+ ".jpg": "image/jpeg",
48
+ ".jpeg": "image/jpeg",
49
+ ".png": "image/png",
50
+ ".gif": "image/gif",
51
+ ".webp": "image/webp",
52
+ ".bmp": "image/bmp",
53
+ ".tiff": "image/tiff",
54
+ ".tif": "image/tiff",
55
+ }
56
+ return media_types.get(ext, "image/jpeg")
57
+
58
+
59
+ def validate_image_size(
60
+ image_path: str, max_size_mb: float = 20.0
61
+ ) -> Tuple[bool, Optional[str]]:
62
+ """
63
+ Validate image file size.
64
+
65
+ Args:
66
+ image_path: Path to the image file
67
+ max_size_mb: Maximum allowed size in megabytes
68
+
69
+ Returns:
70
+ Tuple of (is_valid, error_message)
71
+ """
72
+ import os
73
+
74
+ try:
75
+ size_bytes = os.path.getsize(image_path)
76
+ size_mb = size_bytes / (1024 * 1024)
77
+
78
+ if size_mb > max_size_mb:
79
+ return False, f"Image size {size_mb:.1f}MB exceeds maximum {max_size_mb}MB"
80
+
81
+ return True, None
82
+ except Exception as e:
83
+ return False, f"Failed to check image size: {e}"
84
+
85
+
86
+ def resize_image_if_needed(
87
+ image_path: str, max_size_mb: float = 20.0, max_dimension: int = 4096
88
+ ) -> Optional[str]:
89
+ """
90
+ Resize image if it exceeds size or dimension limits.
91
+
92
+ Args:
93
+ image_path: Path to the image file
94
+ max_size_mb: Maximum file size in MB
95
+ max_dimension: Maximum width or height in pixels
96
+
97
+ Returns:
98
+ Base64 encoded resized image, or None if no resize needed
99
+ """
100
+ try:
101
+ # Lazy import to avoid PIL dependency when not using vision
102
+ import base64
103
+ import io
104
+
105
+ from PIL import Image
106
+
107
+ # Check if resize is needed
108
+ is_valid, _ = validate_image_size(image_path, max_size_mb)
109
+
110
+ with Image.open(image_path) as img:
111
+ # Check dimensions
112
+ needs_resize = (
113
+ not is_valid or img.width > max_dimension or img.height > max_dimension
114
+ )
115
+
116
+ if not needs_resize:
117
+ return None
118
+
119
+ # Calculate new size maintaining aspect ratio
120
+ ratio = min(max_dimension / img.width, max_dimension / img.height, 1.0)
121
+ new_size = (int(img.width * ratio), int(img.height * ratio))
122
+
123
+ # Resize image
124
+ img = img.resize(new_size, Image.Resampling.LANCZOS)
125
+
126
+ # Convert to RGB if necessary (for JPEG)
127
+ if img.mode not in ("RGB", "L"):
128
+ img = img.convert("RGB")
129
+
130
+ # Save to bytes
131
+ output = io.BytesIO()
132
+ img_format = (
133
+ "JPEG"
134
+ if Path(image_path).suffix.lower() in [".jpg", ".jpeg"]
135
+ else "PNG"
136
+ )
137
+ img.save(output, format=img_format, optimize=True, quality=85)
138
+
139
+ # Encode to base64
140
+ output.seek(0)
141
+ return base64.b64encode(output.read()).decode("utf-8")
142
+
143
+ except ImportError:
144
+ # PIL not available, skip resizing
145
+ return None
146
+ except Exception:
147
+ # Any error in resizing, return None to use original
148
+ return None
@@ -0,0 +1,26 @@
1
+ """Alert and notification nodes for the Kailash SDK.
2
+
3
+ This module provides specialized nodes for sending alerts and notifications
4
+ through various channels. Each alert node follows a consistent interface while
5
+ providing channel-specific features and optimizations.
6
+
7
+ The module includes:
8
+ - Base alert node infrastructure
9
+ - Discord webhook integration
10
+ - (Future) Slack, email, webhook, and other integrations
11
+
12
+ Design Philosophy:
13
+ - Provide purpose-built nodes for common alert patterns
14
+ - Abstract channel-specific complexity
15
+ - Support both simple and advanced use cases
16
+ - Enable consistent alert formatting across channels
17
+ """
18
+
19
+ from .base import AlertNode, AlertSeverity
20
+ from .discord import DiscordAlertNode
21
+
22
+ __all__ = [
23
+ "AlertNode",
24
+ "AlertSeverity",
25
+ "DiscordAlertNode",
26
+ ]