cognitive-modules 0.5.0__py3-none-any.whl → 0.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.
cognitive/runner.py CHANGED
@@ -10,8 +10,13 @@ v2.2 Features:
10
10
  """
11
11
 
12
12
  import json
13
+ import base64
14
+ import mimetypes
13
15
  from pathlib import Path
14
- from typing import Optional, TypedDict, Union, Literal
16
+ from typing import Optional, TypedDict, Union, Literal, Callable, AsyncIterator
17
+ from dataclasses import dataclass, field
18
+ from urllib.request import urlopen
19
+ from urllib.error import URLError
15
20
 
16
21
  import jsonschema
17
22
  import yaml
@@ -80,21 +85,49 @@ EnvelopeResponse = Union[EnvelopeResponseV22, EnvelopeSuccessV21, EnvelopeFailur
80
85
  RISK_LEVELS = {"none": 0, "low": 1, "medium": 2, "high": 3}
81
86
  RISK_NAMES = ["none", "low", "medium", "high"]
82
87
 
88
+ RiskRule = Literal["max_changes_risk", "max_issues_risk", "explicit"]
83
89
 
84
- def aggregate_risk(changes: list[dict]) -> RiskLevel:
85
- """Compute max risk from list of changes."""
86
- if not changes:
90
+
91
+ def aggregate_risk_from_list(items: list[dict]) -> RiskLevel:
92
+ """Compute max risk from list of items with risk field."""
93
+ if not items:
87
94
  return "medium" # Default conservative
88
95
 
89
96
  max_level = 0
90
- for change in changes:
91
- risk = change.get("risk", "medium")
97
+ for item in items:
98
+ risk = item.get("risk", "medium")
92
99
  level = RISK_LEVELS.get(risk, 2)
93
100
  max_level = max(max_level, level)
94
101
 
95
102
  return RISK_NAMES[max_level]
96
103
 
97
104
 
105
+ def aggregate_risk(
106
+ data: dict,
107
+ risk_rule: RiskRule = "max_changes_risk"
108
+ ) -> RiskLevel:
109
+ """
110
+ Compute aggregated risk based on risk_rule.
111
+
112
+ Rules:
113
+ - max_changes_risk: max(data.changes[*].risk) - default
114
+ - max_issues_risk: max(data.issues[*].risk) - for review modules
115
+ - explicit: return "medium", module should set risk explicitly
116
+ """
117
+ if risk_rule == "max_changes_risk":
118
+ changes = data.get("changes", [])
119
+ return aggregate_risk_from_list(changes)
120
+ elif risk_rule == "max_issues_risk":
121
+ issues = data.get("issues", [])
122
+ return aggregate_risk_from_list(issues)
123
+ elif risk_rule == "explicit":
124
+ return "medium" # Module should override
125
+ else:
126
+ # Fallback to changes
127
+ changes = data.get("changes", [])
128
+ return aggregate_risk_from_list(changes)
129
+
130
+
98
131
  # =============================================================================
99
132
  # Schema Validation
100
133
  # =============================================================================
@@ -120,15 +153,20 @@ def validate_data(data: dict, schema: dict, label: str = "Data") -> list[str]:
120
153
  def repair_envelope(
121
154
  data: dict,
122
155
  meta_schema: Optional[dict] = None,
123
- max_explain_length: int = 280
156
+ max_explain_length: int = 280,
157
+ risk_rule: RiskRule = "max_changes_risk"
124
158
  ) -> dict:
125
159
  """
126
160
  Attempt to repair envelope format issues without changing semantics.
127
161
 
128
- Repairs:
162
+ Repairs (mostly lossless, except explain truncation):
129
163
  - Missing meta fields (fill with conservative defaults)
130
- - Truncate explain if too long
131
- - Normalize risk enum values
164
+ - Truncate explain if too long (lossy operation, but required for v2.2 spec)
165
+ - Trim whitespace from string fields (lossless)
166
+ - Clamp confidence to [0, 1] range (lossy if out of range)
167
+
168
+ Does NOT repair:
169
+ - Invalid enum values (treated as validation failure)
132
170
  """
133
171
  repaired = dict(data)
134
172
 
@@ -148,28 +186,28 @@ def repair_envelope(
148
186
  if isinstance(meta.get("confidence"), (int, float)):
149
187
  meta["confidence"] = max(0.0, min(1.0, float(meta["confidence"])))
150
188
 
151
- # Repair risk
189
+ # Repair risk - use configurable aggregation rule
152
190
  if "risk" not in meta:
153
- # Aggregate from changes if available
154
- changes = data_payload.get("changes", [])
155
- meta["risk"] = aggregate_risk(changes)
156
-
157
- # Normalize risk value
158
- risk = str(meta.get("risk", "medium")).lower()
159
- if risk not in RISK_LEVELS:
160
- meta["risk"] = "medium"
161
- else:
162
- meta["risk"] = risk
191
+ meta["risk"] = aggregate_risk(data_payload, risk_rule)
192
+
193
+ # Trim whitespace from risk (lossless), but do NOT invent new values
194
+ if isinstance(meta.get("risk"), str):
195
+ meta["risk"] = meta["risk"].strip().lower()
196
+ # If invalid after trim, leave as-is (validation will catch it)
163
197
 
164
198
  # Repair explain
165
199
  if "explain" not in meta:
166
200
  # Try to extract from rationale
167
201
  rationale = data_payload.get("rationale", "")
168
202
  if rationale:
169
- meta["explain"] = rationale[:max_explain_length]
203
+ meta["explain"] = str(rationale)[:max_explain_length]
170
204
  else:
171
205
  meta["explain"] = "No explanation provided"
172
206
 
207
+ # Trim whitespace from explain (lossless)
208
+ if isinstance(meta.get("explain"), str):
209
+ meta["explain"] = meta["explain"].strip()
210
+
173
211
  # Truncate explain if too long
174
212
  if len(meta.get("explain", "")) > max_explain_length:
175
213
  meta["explain"] = meta["explain"][:max_explain_length - 3] + "..."
@@ -230,13 +268,12 @@ def wrap_v21_to_v22(v21_response: dict) -> EnvelopeResponseV22:
230
268
  # Extract or compute meta fields
231
269
  confidence = data.get("confidence", 0.5)
232
270
  rationale = data.get("rationale", "")
233
- changes = data.get("changes", [])
234
271
 
235
272
  return {
236
273
  "ok": True,
237
274
  "meta": {
238
275
  "confidence": confidence,
239
- "risk": aggregate_risk(changes),
276
+ "risk": aggregate_risk(data), # Uses default max_changes_risk
240
277
  "explain": rationale[:280] if rationale else "No explanation provided"
241
278
  },
242
279
  "data": data
@@ -279,13 +316,12 @@ def convert_legacy_to_envelope(data: dict, is_error: bool = False) -> EnvelopeRe
279
316
  # Legacy success response - data is the payload itself
280
317
  confidence = data.get("confidence", 0.5)
281
318
  rationale = data.get("rationale", "")
282
- changes = data.get("changes", [])
283
319
 
284
320
  return {
285
321
  "ok": True,
286
322
  "meta": {
287
323
  "confidence": confidence,
288
- "risk": aggregate_risk(changes),
324
+ "risk": aggregate_risk(data), # Uses default max_changes_risk
289
325
  "explain": rationale[:280] if rationale else "No explanation provided"
290
326
  },
291
327
  "data": data
@@ -513,13 +549,17 @@ def run_module(
513
549
  data_schema = module.get("data_schema") or module.get("output_schema")
514
550
  meta_schema = module.get("meta_schema")
515
551
 
552
+ # Get risk_rule from module.yaml meta config
553
+ meta_config = module.get("metadata", {}).get("meta", {})
554
+ risk_rule = meta_config.get("risk_rule", "max_changes_risk")
555
+
516
556
  if data_schema:
517
557
  data_to_validate = result.get("data", {})
518
558
  errors = validate_data(data_to_validate, data_schema, "Data")
519
559
 
520
560
  if errors and enable_repair:
521
561
  # Attempt repair pass
522
- result = repair_envelope(result, meta_schema)
562
+ result = repair_envelope(result, meta_schema, risk_rule=risk_rule)
523
563
 
524
564
  # Re-validate after repair
525
565
  errors = validate_data(result.get("data", {}), data_schema, "Data")
@@ -540,7 +580,21 @@ def run_module(
540
580
  if meta_schema:
541
581
  meta_errors = validate_data(result.get("meta", {}), meta_schema, "Meta")
542
582
  if meta_errors and enable_repair:
543
- result = repair_envelope(result, meta_schema)
583
+ result = repair_envelope(result, meta_schema, risk_rule=risk_rule)
584
+ # Re-validate meta after repair
585
+ meta_errors = validate_data(result.get("meta", {}), meta_schema, "Meta")
586
+ if meta_errors:
587
+ # Meta validation failed after repair attempt
588
+ return {
589
+ "ok": False,
590
+ "meta": {
591
+ "confidence": 0.0,
592
+ "risk": "high",
593
+ "explain": "Meta schema validation failed after repair attempt."
594
+ },
595
+ "error": {"code": "META_VALIDATION_FAILED", "message": str(meta_errors)},
596
+ "partial_data": result.get("data")
597
+ }
544
598
 
545
599
  return result
546
600
 
@@ -603,3 +657,499 @@ def should_escalate(result: EnvelopeResponseV22, confidence_threshold: float = 0
603
657
  return True
604
658
 
605
659
  return False
660
+
661
+
662
+ # =============================================================================
663
+ # v2.5 Streaming Support
664
+ # =============================================================================
665
+
666
+ import uuid
667
+ from typing import AsyncIterator, Iterator, Any, Callable
668
+ from dataclasses import dataclass, field
669
+
670
+
671
+ @dataclass
672
+ class StreamingSession:
673
+ """Represents an active streaming session."""
674
+ session_id: str
675
+ module_name: str
676
+ started_at: float = field(default_factory=lambda: __import__('time').time())
677
+ chunks_sent: int = 0
678
+ accumulated_data: dict = field(default_factory=dict)
679
+ accumulated_text: dict = field(default_factory=dict) # field -> accumulated string
680
+
681
+
682
+ def create_session_id() -> str:
683
+ """Generate a unique session ID for streaming."""
684
+ return f"sess_{uuid.uuid4().hex[:12]}"
685
+
686
+
687
+ def create_meta_chunk(session_id: str, initial_risk: str = "low") -> dict:
688
+ """Create the initial meta chunk for streaming."""
689
+ return {
690
+ "ok": True,
691
+ "streaming": True,
692
+ "session_id": session_id,
693
+ "meta": {
694
+ "confidence": None,
695
+ "risk": initial_risk,
696
+ "explain": "Processing..."
697
+ }
698
+ }
699
+
700
+
701
+ def create_delta_chunk(seq: int, field: str, delta: str) -> dict:
702
+ """Create a delta chunk for incremental content."""
703
+ return {
704
+ "chunk": {
705
+ "seq": seq,
706
+ "type": "delta",
707
+ "field": field,
708
+ "delta": delta
709
+ }
710
+ }
711
+
712
+
713
+ def create_snapshot_chunk(seq: int, field: str, data: Any) -> dict:
714
+ """Create a snapshot chunk for full field replacement."""
715
+ return {
716
+ "chunk": {
717
+ "seq": seq,
718
+ "type": "snapshot",
719
+ "field": field,
720
+ "data": data
721
+ }
722
+ }
723
+
724
+
725
+ def create_progress_chunk(percent: int, stage: str = "", message: str = "") -> dict:
726
+ """Create a progress update chunk."""
727
+ return {
728
+ "progress": {
729
+ "percent": percent,
730
+ "stage": stage,
731
+ "message": message
732
+ }
733
+ }
734
+
735
+
736
+ def create_final_chunk(meta: dict, data: dict, usage: dict = None) -> dict:
737
+ """Create the final chunk with complete data."""
738
+ chunk = {
739
+ "final": True,
740
+ "meta": meta,
741
+ "data": data
742
+ }
743
+ if usage:
744
+ chunk["usage"] = usage
745
+ return chunk
746
+
747
+
748
+ def create_error_chunk(session_id: str, error_code: str, message: str,
749
+ recoverable: bool = False, partial_data: dict = None) -> dict:
750
+ """Create an error chunk for stream failures."""
751
+ chunk = {
752
+ "ok": False,
753
+ "streaming": True,
754
+ "session_id": session_id,
755
+ "error": {
756
+ "code": error_code,
757
+ "message": message,
758
+ "recoverable": recoverable
759
+ }
760
+ }
761
+ if partial_data:
762
+ chunk["partial_data"] = partial_data
763
+ return chunk
764
+
765
+
766
+ def assemble_streamed_data(session: StreamingSession) -> dict:
767
+ """Assemble accumulated streaming data into final format."""
768
+ data = session.accumulated_data.copy()
769
+
770
+ # Merge accumulated text fields
771
+ for field_path, text in session.accumulated_text.items():
772
+ parts = field_path.split(".")
773
+ target = data
774
+ for part in parts[:-1]:
775
+ if part not in target:
776
+ target[part] = {}
777
+ target = target[part]
778
+ target[parts[-1]] = text
779
+
780
+ return data
781
+
782
+
783
+ class StreamingRunner:
784
+ """Runner with streaming support for v2.5 modules."""
785
+
786
+ def __init__(self, provider_callback: Callable = None):
787
+ """
788
+ Initialize streaming runner.
789
+
790
+ Args:
791
+ provider_callback: Function to call LLM with streaming support.
792
+ Signature: async (prompt, images=None) -> AsyncIterator[str]
793
+ """
794
+ self.provider_callback = provider_callback or self._default_provider
795
+ self.active_sessions: dict[str, StreamingSession] = {}
796
+
797
+ async def _default_provider(self, prompt: str, images: list = None) -> AsyncIterator[str]:
798
+ """Default provider - yields entire response at once (for testing)."""
799
+ # In real implementation, this would stream from LLM
800
+ yield '{"ok": true, "meta": {"confidence": 0.9, "risk": "low", "explain": "Test"}, "data": {"rationale": "Test response"}}'
801
+
802
+ async def execute_stream(
803
+ self,
804
+ module_name: str,
805
+ input_data: dict,
806
+ on_chunk: Callable[[dict], None] = None
807
+ ) -> AsyncIterator[dict]:
808
+ """
809
+ Execute a module with streaming output.
810
+
811
+ Args:
812
+ module_name: Name of the module to execute
813
+ input_data: Input data including multimodal content
814
+ on_chunk: Optional callback for each chunk
815
+
816
+ Yields:
817
+ Streaming chunks (meta, delta, progress, final, or error)
818
+ """
819
+ session_id = create_session_id()
820
+ session = StreamingSession(session_id=session_id, module_name=module_name)
821
+ self.active_sessions[session_id] = session
822
+
823
+ try:
824
+ # Load module
825
+ module = load_module(module_name)
826
+
827
+ # Check if module supports streaming
828
+ response_config = module.get("response", {})
829
+ mode = response_config.get("mode", "sync")
830
+ if mode not in ("streaming", "both"):
831
+ # Fall back to sync execution
832
+ result = await self._execute_sync(module, input_data)
833
+ yield create_meta_chunk(session_id)
834
+ yield create_final_chunk(result["meta"], result["data"])
835
+ return
836
+
837
+ # Extract images for multimodal
838
+ images = self._extract_media(input_data)
839
+
840
+ # Build prompt
841
+ prompt = self._build_prompt(module, input_data)
842
+
843
+ # Send initial meta chunk
844
+ meta_chunk = create_meta_chunk(session_id)
845
+ if on_chunk:
846
+ on_chunk(meta_chunk)
847
+ yield meta_chunk
848
+
849
+ # Stream from LLM
850
+ seq = 1
851
+ accumulated_response = ""
852
+
853
+ async for text_chunk in self.provider_callback(prompt, images):
854
+ accumulated_response += text_chunk
855
+
856
+ # Create delta chunk for rationale field
857
+ delta_chunk = create_delta_chunk(seq, "data.rationale", text_chunk)
858
+ session.chunks_sent += 1
859
+ session.accumulated_text.setdefault("data.rationale", "")
860
+ session.accumulated_text["data.rationale"] += text_chunk
861
+
862
+ if on_chunk:
863
+ on_chunk(delta_chunk)
864
+ yield delta_chunk
865
+ seq += 1
866
+
867
+ # Parse final response
868
+ try:
869
+ final_data = parse_llm_response(accumulated_response)
870
+ final_data = repair_envelope(final_data)
871
+ except Exception as e:
872
+ error_chunk = create_error_chunk(
873
+ session_id, "E2001", str(e),
874
+ recoverable=False,
875
+ partial_data={"rationale": session.accumulated_text.get("data.rationale", "")}
876
+ )
877
+ yield error_chunk
878
+ return
879
+
880
+ # Send final chunk
881
+ final_chunk = create_final_chunk(
882
+ final_data.get("meta", {}),
883
+ final_data.get("data", {}),
884
+ {"input_tokens": 0, "output_tokens": seq} # Placeholder
885
+ )
886
+ if on_chunk:
887
+ on_chunk(final_chunk)
888
+ yield final_chunk
889
+
890
+ except Exception as e:
891
+ error_chunk = create_error_chunk(
892
+ session_id, "E2010", f"Stream error: {str(e)}",
893
+ recoverable=False
894
+ )
895
+ yield error_chunk
896
+ finally:
897
+ del self.active_sessions[session_id]
898
+
899
+ async def _execute_sync(self, module: dict, input_data: dict) -> dict:
900
+ """Execute module synchronously (fallback)."""
901
+ # Use existing sync execution
902
+ return run_module(module["name"], input_data)
903
+
904
+ def _build_prompt(self, module: dict, input_data: dict) -> str:
905
+ """Build prompt from module and input."""
906
+ prompt_template = module.get("prompt", "")
907
+ return substitute_arguments(prompt_template, input_data)
908
+
909
+ def _extract_media(self, input_data: dict) -> list:
910
+ """Extract media inputs from input data."""
911
+ images = input_data.get("images", [])
912
+ audio = input_data.get("audio", [])
913
+ video = input_data.get("video", [])
914
+ return images + audio + video
915
+
916
+
917
+ # =============================================================================
918
+ # v2.5 Multimodal Support
919
+ # =============================================================================
920
+
921
+ SUPPORTED_IMAGE_TYPES = {
922
+ "image/jpeg", "image/png", "image/webp", "image/gif"
923
+ }
924
+
925
+ SUPPORTED_AUDIO_TYPES = {
926
+ "audio/mpeg", "audio/wav", "audio/ogg", "audio/webm"
927
+ }
928
+
929
+ SUPPORTED_VIDEO_TYPES = {
930
+ "video/mp4", "video/webm", "video/quicktime"
931
+ }
932
+
933
+
934
+ def validate_media_input(media: dict, constraints: dict = None) -> tuple[bool, str]:
935
+ """
936
+ Validate a media input object.
937
+
938
+ Returns:
939
+ Tuple of (is_valid, error_message)
940
+ """
941
+ constraints = constraints or {}
942
+
943
+ media_type = media.get("type")
944
+ if media_type not in ("url", "base64", "file"):
945
+ return False, "Invalid media type. Must be url, base64, or file"
946
+
947
+ if media_type == "url":
948
+ url = media.get("url")
949
+ if not url:
950
+ return False, "URL media missing 'url' field"
951
+ if not url.startswith(("http://", "https://")):
952
+ return False, "URL must start with http:// or https://"
953
+
954
+ elif media_type == "base64":
955
+ mime_type = media.get("media_type")
956
+ if not mime_type:
957
+ return False, "Base64 media missing 'media_type' field"
958
+ data = media.get("data")
959
+ if not data:
960
+ return False, "Base64 media missing 'data' field"
961
+ # Validate base64
962
+ try:
963
+ base64.b64decode(data)
964
+ except Exception:
965
+ return False, "Invalid base64 encoding"
966
+
967
+ # Check size
968
+ max_size = constraints.get("max_size_bytes", 20 * 1024 * 1024) # 20MB default
969
+ data_size = len(data) * 3 // 4 # Approximate decoded size
970
+ if data_size > max_size:
971
+ return False, f"Media exceeds size limit ({data_size} > {max_size} bytes)"
972
+
973
+ elif media_type == "file":
974
+ path = media.get("path")
975
+ if not path:
976
+ return False, "File media missing 'path' field"
977
+ if not Path(path).exists():
978
+ return False, f"File not found: {path}"
979
+
980
+ return True, ""
981
+
982
+
983
+ def load_media_as_base64(media: dict) -> tuple[str, str]:
984
+ """
985
+ Load media from any source and return as base64.
986
+
987
+ Returns:
988
+ Tuple of (base64_data, media_type)
989
+ """
990
+ media_type = media.get("type")
991
+
992
+ if media_type == "base64":
993
+ return media["data"], media["media_type"]
994
+
995
+ elif media_type == "url":
996
+ url = media["url"]
997
+ try:
998
+ with urlopen(url, timeout=30) as response:
999
+ data = response.read()
1000
+ content_type = response.headers.get("Content-Type", "application/octet-stream")
1001
+ # Extract just the mime type (remove charset etc)
1002
+ content_type = content_type.split(";")[0].strip()
1003
+ return base64.b64encode(data).decode("utf-8"), content_type
1004
+ except URLError as e:
1005
+ raise ValueError(f"Failed to fetch media from URL: {e}")
1006
+
1007
+ elif media_type == "file":
1008
+ path = Path(media["path"])
1009
+ if not path.exists():
1010
+ raise ValueError(f"File not found: {path}")
1011
+
1012
+ mime_type, _ = mimetypes.guess_type(str(path))
1013
+ mime_type = mime_type or "application/octet-stream"
1014
+
1015
+ with open(path, "rb") as f:
1016
+ data = f.read()
1017
+
1018
+ return base64.b64encode(data).decode("utf-8"), mime_type
1019
+
1020
+ raise ValueError(f"Unknown media type: {media_type}")
1021
+
1022
+
1023
+ def prepare_media_for_llm(media_list: list, provider: str = "openai") -> list:
1024
+ """
1025
+ Prepare media inputs for specific LLM provider format.
1026
+
1027
+ Different providers have different multimodal input formats:
1028
+ - OpenAI: {"type": "image_url", "image_url": {"url": "data:..."}}
1029
+ - Anthropic: {"type": "image", "source": {"type": "base64", ...}}
1030
+ - Google: {"inlineData": {"mimeType": "...", "data": "..."}}
1031
+ """
1032
+ prepared = []
1033
+
1034
+ for media in media_list:
1035
+ data, mime_type = load_media_as_base64(media)
1036
+
1037
+ if provider == "openai":
1038
+ prepared.append({
1039
+ "type": "image_url",
1040
+ "image_url": {
1041
+ "url": f"data:{mime_type};base64,{data}"
1042
+ }
1043
+ })
1044
+ elif provider == "anthropic":
1045
+ prepared.append({
1046
+ "type": "image",
1047
+ "source": {
1048
+ "type": "base64",
1049
+ "media_type": mime_type,
1050
+ "data": data
1051
+ }
1052
+ })
1053
+ elif provider == "google":
1054
+ prepared.append({
1055
+ "inlineData": {
1056
+ "mimeType": mime_type,
1057
+ "data": data
1058
+ }
1059
+ })
1060
+ else:
1061
+ # Generic format
1062
+ prepared.append({
1063
+ "type": "base64",
1064
+ "media_type": mime_type,
1065
+ "data": data
1066
+ })
1067
+
1068
+ return prepared
1069
+
1070
+
1071
+ def get_modalities_config(module: dict) -> dict:
1072
+ """Get modalities configuration from module."""
1073
+ return module.get("modalities", {
1074
+ "input": ["text"],
1075
+ "output": ["text"]
1076
+ })
1077
+
1078
+
1079
+ def supports_multimodal_input(module: dict) -> bool:
1080
+ """Check if module supports multimodal input."""
1081
+ modalities = get_modalities_config(module)
1082
+ input_modalities = modalities.get("input", ["text"])
1083
+ return any(m in input_modalities for m in ["image", "audio", "video"])
1084
+
1085
+
1086
+ def supports_multimodal_output(module: dict) -> bool:
1087
+ """Check if module supports multimodal output."""
1088
+ modalities = get_modalities_config(module)
1089
+ output_modalities = modalities.get("output", ["text"])
1090
+ return any(m in output_modalities for m in ["image", "audio", "video"])
1091
+
1092
+
1093
+ def validate_multimodal_input(input_data: dict, module: dict) -> tuple[bool, list[str]]:
1094
+ """
1095
+ Validate multimodal input against module configuration.
1096
+
1097
+ Returns:
1098
+ Tuple of (is_valid, list of errors)
1099
+ """
1100
+ errors = []
1101
+ modalities = get_modalities_config(module)
1102
+ input_modalities = set(modalities.get("input", ["text"]))
1103
+ constraints = modalities.get("constraints", {})
1104
+
1105
+ # Check images
1106
+ images = input_data.get("images", [])
1107
+ if images:
1108
+ if "image" not in input_modalities:
1109
+ errors.append("Module does not support image input")
1110
+ else:
1111
+ max_images = constraints.get("max_images", 10)
1112
+ if len(images) > max_images:
1113
+ errors.append(f"Too many images ({len(images)} > {max_images})")
1114
+
1115
+ for i, img in enumerate(images):
1116
+ valid, err = validate_media_input(img, constraints)
1117
+ if not valid:
1118
+ errors.append(f"Image {i}: {err}")
1119
+
1120
+ # Check audio
1121
+ audio = input_data.get("audio", [])
1122
+ if audio:
1123
+ if "audio" not in input_modalities:
1124
+ errors.append("Module does not support audio input")
1125
+
1126
+ # Check video
1127
+ video = input_data.get("video", [])
1128
+ if video:
1129
+ if "video" not in input_modalities:
1130
+ errors.append("Module does not support video input")
1131
+
1132
+ return len(errors) == 0, errors
1133
+
1134
+
1135
+ # =============================================================================
1136
+ # v2.5 Runtime Capabilities
1137
+ # =============================================================================
1138
+
1139
+ def get_runtime_capabilities() -> dict:
1140
+ """Get runtime capabilities for v2.5."""
1141
+ return {
1142
+ "runtime": "cognitive-runtime-python",
1143
+ "version": "2.5.0",
1144
+ "spec_version": "2.5",
1145
+ "capabilities": {
1146
+ "streaming": True,
1147
+ "multimodal": {
1148
+ "input": ["image"], # Basic image support
1149
+ "output": [] # No generation yet
1150
+ },
1151
+ "max_media_size_mb": 20,
1152
+ "supported_transports": ["ndjson"], # SSE requires async server
1153
+ "conformance_level": 4
1154
+ }
1155
+ }