symbolicai 1.1.1__py3-none-any.whl → 1.2.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.
symai/__init__.py CHANGED
@@ -33,7 +33,7 @@ os.environ["TOKENIZERS_PARALLELISM"] = "false"
33
33
  # Create singleton instance
34
34
  config_manager = settings.SymAIConfig()
35
35
 
36
- SYMAI_VERSION = "1.1.1"
36
+ SYMAI_VERSION = "1.2.0"
37
37
  __version__ = SYMAI_VERSION
38
38
  __root_dir__ = config_manager.config_dir
39
39
 
@@ -22,7 +22,9 @@ try:
22
22
  from qdrant_client.http.models import (
23
23
  Distance,
24
24
  Filter,
25
+ NamedVector,
25
26
  PointStruct,
27
+ Query,
26
28
  ScoredPoint,
27
29
  VectorParams,
28
30
  )
@@ -33,6 +35,8 @@ except ImportError:
33
35
  VectorParams = None
34
36
  PointStruct = None
35
37
  Filter = None
38
+ Query = None
39
+ NamedVector = None
36
40
  ScoredPoint = None
37
41
 
38
42
  try:
@@ -322,6 +326,50 @@ class QdrantIndexEngine(Engine):
322
326
  # Reinitialize client to refresh collection list
323
327
  self._init_client()
324
328
 
329
+ def _build_query_filter(self, raw_filter: Any) -> Filter | None:
330
+ """Normalize various filter representations into a Qdrant Filter.
331
+
332
+ Supports:
333
+ - None: returns None
334
+ - Existing Filter instance: returned as-is
335
+ - Dict[str, Any]: converted to equality-based Filter over payload keys
336
+
337
+ The dict form is intentionally simple and maps directly to `payload.<key>`
338
+ equality conditions, which covers the majority of RAG use cases while
339
+ remaining easy to serialize and pass through higher-level APIs.
340
+ """
341
+ if raw_filter is None or Filter is None:
342
+ return None
343
+
344
+ # Already a Filter instance → use directly
345
+ if isinstance(raw_filter, Filter):
346
+ return raw_filter
347
+
348
+ # Simple dict → build equality-based must filter
349
+ if isinstance(raw_filter, dict):
350
+ if models is None:
351
+ UserMessage(
352
+ "Qdrant filter models are not available. "
353
+ "Please install `qdrant-client` to use filtering.",
354
+ raise_with=ImportError,
355
+ )
356
+
357
+ conditions = []
358
+ for key, value in raw_filter.items():
359
+ # We keep semantics simple and robust: every entry is treated as an
360
+ # equality condition on the payload key (logical AND across keys).
361
+ conditions.append(
362
+ models.FieldCondition(
363
+ key=key,
364
+ match=models.MatchValue(value=value),
365
+ )
366
+ )
367
+
368
+ return Filter(must=conditions) if conditions else None
369
+
370
+ # Fallback: pass through other representations (e.g. already-built Filter-like)
371
+ return raw_filter
372
+
325
373
  def _prepare_points_for_upsert(
326
374
  self,
327
375
  embeddings: list | np.ndarray | Any,
@@ -338,7 +386,7 @@ class QdrantIndexEngine(Engine):
338
386
  embeddings = [embeddings]
339
387
 
340
388
  for i, vec in enumerate(embeddings):
341
- point_id = ids[i] if ids and i < len(ids) else i
389
+ point_id = self._normalize_point_id(ids[i]) if ids and i < len(ids) else i
342
390
  payload = payloads[i] if payloads and i < len(payloads) else {}
343
391
  points.append(
344
392
  PointStruct(id=point_id, vector=self._normalize_vector(vec), payload=payload)
@@ -349,6 +397,14 @@ class QdrantIndexEngine(Engine):
349
397
  def forward(self, argument):
350
398
  kwargs = argument.kwargs
351
399
  embedding = argument.prop.prepared_input
400
+ if embedding is None:
401
+ embedding = getattr(argument.prop, "prompt", None)
402
+ if embedding is None:
403
+ msg = (
404
+ "Qdrant forward() requires an embedding vector. "
405
+ "Provide it via prepared_input or prompt before calling forward()."
406
+ )
407
+ raise ValueError(msg)
352
408
  query = argument.prop.ori_query
353
409
  operation = argument.prop.operation
354
410
  collection_name = argument.prop.index_name if argument.prop.index_name else self.index_name
@@ -369,8 +425,20 @@ class QdrantIndexEngine(Engine):
369
425
  # Ensure collection exists - fail fast if it doesn't
370
426
  self._ensure_collection_exists(collection_name)
371
427
  index_top_k = kwargs.get("index_top_k", self.index_top_k)
372
- # Use existing _query method
373
- rsp = self._query(collection_name, embedding, index_top_k)
428
+ # Optional search parameters
429
+ score_threshold = kwargs.get("score_threshold")
430
+ # Accept both `query_filter` and `filter` for convenience
431
+ raw_filter = kwargs.get("query_filter", kwargs.get("filter"))
432
+ query_filter = self._build_query_filter(raw_filter)
433
+
434
+ # Use shared search helper that already handles retries and normalization
435
+ rsp = self._search_sync(
436
+ collection_name=collection_name,
437
+ query_vector=embedding,
438
+ limit=index_top_k,
439
+ score_threshold=score_threshold,
440
+ query_filter=query_filter,
441
+ )
374
442
  elif operation == "add":
375
443
  # Create collection if it doesn't exist (only for write operations)
376
444
  self._create_collection_sync(collection_name, collection_dims, self.index_metric)
@@ -446,14 +514,19 @@ class QdrantIndexEngine(Engine):
446
514
  )
447
515
  def _func():
448
516
  query_vector_normalized = self._normalize_vector(query_vector)
449
- return self.client.search(
517
+ # For single vector collections, pass vector directly to query parameter
518
+ # For named vector collections, use Query(near_vector=NamedVector(name="vector_name", vector=...))
519
+ # query_points API uses query_filter (not filter) for filtering
520
+ response = self.client.query_points(
450
521
  collection_name=collection_name,
451
- query_vector=query_vector_normalized,
522
+ query=query_vector_normalized,
452
523
  limit=top_k,
453
524
  with_payload=True,
454
525
  with_vectors=self.index_values,
455
526
  **kwargs,
456
527
  )
528
+ # query_points returns QueryResponse with .points attribute, extract it
529
+ return response.points
457
530
 
458
531
  return _func()
459
532
 
@@ -563,16 +636,67 @@ class QdrantIndexEngine(Engine):
563
636
  for name, vec in vector_config.items()
564
637
  }
565
638
  }
566
- return {
639
+ # Qdrant 1.16.1+ compatibility: vectors_count and indexed_vectors_count may not exist
640
+ # Use points_count as the primary count, and try to get vectors_count if available
641
+ result = {
567
642
  "name": collection_name,
568
- "vectors_count": collection_info.vectors_count,
569
- "indexed_vectors_count": collection_info.indexed_vectors_count,
570
643
  "points_count": collection_info.points_count,
571
644
  "config": {"params": {"vectors": vectors_info}},
572
645
  }
573
646
 
647
+ # Try to get vectors_count if available (for older Qdrant versions)
648
+ if hasattr(collection_info, "vectors_count"):
649
+ result["vectors_count"] = collection_info.vectors_count
650
+ else:
651
+ # In Qdrant 1.16.1+, vectors_count is not available, use points_count as approximation
652
+ result["vectors_count"] = collection_info.points_count
653
+
654
+ # Try to get indexed_vectors_count if available
655
+ if hasattr(collection_info, "indexed_vectors_count"):
656
+ result["indexed_vectors_count"] = collection_info.indexed_vectors_count
657
+ else:
658
+ # In Qdrant 1.16.1+, indexed_vectors_count may not be available
659
+ result["indexed_vectors_count"] = collection_info.points_count
660
+
661
+ return result
662
+
574
663
  # ==================== Point Operations ====================
575
664
 
665
+ def _normalize_point_id(self, point_id: Any) -> int | uuid.UUID:
666
+ """Normalize point ID to integer or UUID for Qdrant 1.16.1+ compatibility.
667
+
668
+ Qdrant 1.16.1+ requires point IDs to be either unsigned integers or UUIDs.
669
+ This function converts string IDs (like 'vec-1') to integers or UUIDs.
670
+ """
671
+ # If already int or UUID, return as-is
672
+ if isinstance(point_id, (int, uuid.UUID)):
673
+ return point_id
674
+
675
+ # If string, try to convert
676
+ if isinstance(point_id, str):
677
+ # Try to parse as integer first
678
+ try:
679
+ # Handle string IDs like "vec-1" by extracting the number
680
+ if point_id.startswith("vec-"):
681
+ num_str = point_id.split("-", 1)[-1]
682
+ return int(num_str)
683
+ # Try direct integer conversion
684
+ return int(point_id)
685
+ except (ValueError, AttributeError):
686
+ # If not a valid integer, try UUID
687
+ try:
688
+ return uuid.UUID(point_id)
689
+ except (ValueError, AttributeError):
690
+ # Fallback: generate UUID from string hash
691
+ return uuid.uuid5(uuid.NAMESPACE_DNS, point_id)
692
+
693
+ # For other types, try to convert to int
694
+ try:
695
+ return int(point_id)
696
+ except (ValueError, TypeError):
697
+ # Last resort: generate UUID from string representation
698
+ return uuid.uuid5(uuid.NAMESPACE_DNS, str(point_id))
699
+
576
700
  def _upsert_points_sync(
577
701
  self,
578
702
  collection_name: str,
@@ -589,17 +713,17 @@ class QdrantIndexEngine(Engine):
589
713
  if isinstance(points[0], dict):
590
714
  points = [
591
715
  PointStruct(
592
- id=point["id"],
716
+ id=self._normalize_point_id(point["id"]),
593
717
  vector=self._normalize_vector(point["vector"]),
594
718
  payload=point.get("payload", {}),
595
719
  )
596
720
  for point in points
597
721
  ]
598
722
  else:
599
- # Normalize vectors in existing PointStruct objects
723
+ # Normalize vectors and IDs in existing PointStruct objects
600
724
  points = [
601
725
  PointStruct(
602
- id=point.id,
726
+ id=self._normalize_point_id(point.id),
603
727
  vector=self._normalize_vector(point.vector),
604
728
  payload=point.payload,
605
729
  )
@@ -11,6 +11,7 @@ from ...mixin import (
11
11
  GROQ_REASONING_MODELS,
12
12
  OPENAI_CHAT_MODELS,
13
13
  OPENAI_REASONING_MODELS,
14
+ OPENAI_RESPONSES_MODELS,
14
15
  )
15
16
  from .engine_anthropic_claudeX_chat import ClaudeXChatEngine
16
17
  from .engine_anthropic_claudeX_reasoning import ClaudeXReasoningEngine
@@ -20,6 +21,7 @@ from .engine_google_geminiX_reasoning import GeminiXReasoningEngine
20
21
  from .engine_groq import GroqEngine
21
22
  from .engine_openai_gptX_chat import GPTXChatEngine
22
23
  from .engine_openai_gptX_reasoning import GPTXReasoningEngine
24
+ from .engine_openai_responses import OpenAIResponsesEngine
23
25
 
24
26
  # create the mapping
25
27
  ENGINE_MAPPING = {
@@ -31,6 +33,7 @@ ENGINE_MAPPING = {
31
33
  **dict.fromkeys(GOOGLE_REASONING_MODELS, GeminiXReasoningEngine),
32
34
  **dict.fromkeys(OPENAI_CHAT_MODELS, GPTXChatEngine),
33
35
  **dict.fromkeys(OPENAI_REASONING_MODELS, GPTXReasoningEngine),
36
+ **dict.fromkeys(OPENAI_RESPONSES_MODELS, OpenAIResponsesEngine),
34
37
  **dict.fromkeys(GROQ_CHAT_MODELS, GroqEngine),
35
38
  **dict.fromkeys(GROQ_REASONING_MODELS, GroqEngine),
36
39
  }
@@ -49,6 +52,7 @@ __all__ = [
49
52
  "GROQ_REASONING_MODELS",
50
53
  "OPENAI_CHAT_MODELS",
51
54
  "OPENAI_REASONING_MODELS",
55
+ "OPENAI_RESPONSES_MODELS",
52
56
  "ClaudeXChatEngine",
53
57
  "ClaudeXReasoningEngine",
54
58
  "DeepSeekXReasoningEngine",
@@ -56,4 +60,5 @@ __all__ = [
56
60
  "GPTXReasoningEngine",
57
61
  "GeminiXReasoningEngine",
58
62
  "GroqEngine",
63
+ "OpenAIResponsesEngine",
59
64
  ]
@@ -0,0 +1,429 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from copy import deepcopy
5
+
6
+ import openai
7
+ import tiktoken
8
+
9
+ from ....components import SelfPrompt
10
+ from ....utils import UserMessage, encode_media_frames
11
+ from ...base import Engine
12
+ from ...mixin.openai import SUPPORTED_REASONING_MODELS, OpenAIMixin
13
+ from ...settings import SYMAI_CONFIG
14
+
15
+ logging.getLogger("openai").setLevel(logging.ERROR)
16
+ logging.getLogger("requests").setLevel(logging.ERROR)
17
+ logging.getLogger("urllib").setLevel(logging.ERROR)
18
+ logging.getLogger("httpx").setLevel(logging.ERROR)
19
+ logging.getLogger("httpcore").setLevel(logging.ERROR)
20
+
21
+
22
+ _NON_VERBOSE_OUTPUT = (
23
+ "<META_INSTRUCTION/>\n"
24
+ "You do not output anything else, like verbose preambles or post explanation, such as "
25
+ '"Sure, let me...", "Hope that was helpful...", "Yes, I can help you with that...", etc. '
26
+ "Consider well formatted output, e.g. for sentences use punctuation, spaces etc. or for code use "
27
+ "indentation, etc. Never add meta instructions information to your output!\n\n"
28
+ )
29
+
30
+
31
+ class ResponsesTokenizer:
32
+ def __init__(self, model: str):
33
+ self._model = model
34
+ try:
35
+ self._tiktoken = tiktoken.encoding_for_model(model)
36
+ except Exception:
37
+ self._tiktoken = tiktoken.get_encoding("o200k_base")
38
+
39
+ def encode(self, text: str) -> list[int]:
40
+ return self._tiktoken.encode(text, disallowed_special=())
41
+
42
+ def decode(self, tokens: list[int]) -> str:
43
+ return self._tiktoken.decode(tokens)
44
+
45
+
46
+ class OpenAIResponsesEngine(Engine, OpenAIMixin):
47
+ def __init__(self, api_key: str | None = None, model: str | None = None):
48
+ super().__init__()
49
+ self.config = deepcopy(SYMAI_CONFIG)
50
+ if api_key is not None and model is not None:
51
+ self.config["NEUROSYMBOLIC_ENGINE_API_KEY"] = api_key
52
+ self.config["NEUROSYMBOLIC_ENGINE_MODEL"] = model
53
+ if self.id() != "neurosymbolic":
54
+ return
55
+ openai.api_key = self.config["NEUROSYMBOLIC_ENGINE_API_KEY"]
56
+ self._prefixed_model = self.config["NEUROSYMBOLIC_ENGINE_MODEL"]
57
+ self.model = self._strip_prefix(self._prefixed_model)
58
+ self.seed = None
59
+ self.name = self.__class__.__name__
60
+ self.tokenizer = ResponsesTokenizer(model=self.model)
61
+ self.max_context_tokens = self.api_max_context_tokens()
62
+ self.max_response_tokens = self.api_max_response_tokens()
63
+
64
+ try:
65
+ self.client = openai.Client(api_key=openai.api_key)
66
+ except Exception as e:
67
+ UserMessage(
68
+ f"Failed to initialize OpenAI client. Caused by: {e}",
69
+ raise_with=ValueError,
70
+ )
71
+
72
+ def _strip_prefix(self, model_name: str) -> str:
73
+ return model_name.replace("responses:", "")
74
+
75
+ def id(self) -> str:
76
+ model = self.config.get("NEUROSYMBOLIC_ENGINE_MODEL")
77
+ if model and model.startswith("responses:"):
78
+ return "neurosymbolic"
79
+ return super().id()
80
+
81
+ def command(self, *args, **kwargs):
82
+ super().command(*args, **kwargs)
83
+ if "NEUROSYMBOLIC_ENGINE_API_KEY" in kwargs:
84
+ openai.api_key = kwargs["NEUROSYMBOLIC_ENGINE_API_KEY"]
85
+ if "NEUROSYMBOLIC_ENGINE_MODEL" in kwargs:
86
+ self._prefixed_model = kwargs["NEUROSYMBOLIC_ENGINE_MODEL"]
87
+ self.model = self._strip_prefix(self._prefixed_model)
88
+ if "seed" in kwargs:
89
+ self.seed = kwargs["seed"]
90
+
91
+ def compute_required_tokens(self, messages: list[dict]) -> int:
92
+ tokens_per_message = 3
93
+ tokens_per_name = 1
94
+ num_tokens = 0
95
+ for message in messages:
96
+ num_tokens += tokens_per_message
97
+ for key, value in message.items():
98
+ if isinstance(value, str):
99
+ num_tokens += len(self.tokenizer.encode(value))
100
+ elif isinstance(value, list):
101
+ for v in value:
102
+ if isinstance(v, dict) and v.get("type") in ("text", "input_text"):
103
+ num_tokens += len(self.tokenizer.encode(v.get("text", "")))
104
+ if key == "name":
105
+ num_tokens += tokens_per_name
106
+ if self._is_reasoning_model():
107
+ num_tokens += 6
108
+ else:
109
+ num_tokens += 3
110
+ return num_tokens
111
+
112
+ def compute_remaining_tokens(self, prompts: list) -> int:
113
+ val = self.compute_required_tokens(prompts)
114
+ return min(self.max_context_tokens - val, self.max_response_tokens)
115
+
116
+ def _is_reasoning_model(self) -> bool:
117
+ return self.model in SUPPORTED_REASONING_MODELS or self.model in {
118
+ "gpt-5.1-chat-latest",
119
+ "gpt-5-pro",
120
+ "o3-pro",
121
+ }
122
+
123
+ def _handle_image_content(self, content: str) -> list[str]:
124
+ def _extract_pattern(text):
125
+ # This regular expression matches <<vision:...:>> patterns to extract embedded image references.
126
+ pattern = r"<<vision:(.*?):>>"
127
+ return re.findall(pattern, text)
128
+
129
+ image_files: list[str] = []
130
+ if "<<vision:" not in content:
131
+ return image_files
132
+
133
+ parts = _extract_pattern(content)
134
+ for p in parts:
135
+ img_ = p.strip()
136
+ if img_.startswith("http") or img_.startswith("data:image"):
137
+ image_files.append(img_)
138
+ else:
139
+ max_frames_spacing = 50
140
+ max_used_frames = 10
141
+ if img_.startswith("frames:"):
142
+ img_ = img_.replace("frames:", "")
143
+ max_used_frames, img_ = img_.split(":")
144
+ max_used_frames = int(max_used_frames)
145
+ if max_used_frames < 1 or max_used_frames > max_frames_spacing:
146
+ UserMessage(
147
+ f"Invalid max_used_frames value: {max_used_frames}. Expected 1-{max_frames_spacing}",
148
+ raise_with=ValueError,
149
+ )
150
+ buffer, ext = encode_media_frames(img_)
151
+ if len(buffer) > 1:
152
+ step = len(buffer) // max_frames_spacing
153
+ indices = list(range(0, len(buffer), step))[:max_used_frames]
154
+ for i in indices:
155
+ image_files.append(f"data:image/{ext};base64,{buffer[i]}")
156
+ elif len(buffer) == 1:
157
+ image_files.append(f"data:image/{ext};base64,{buffer[0]}")
158
+ else:
159
+ UserMessage("No frames found or error in encoding frames")
160
+ return image_files
161
+
162
+ def _remove_vision_pattern(self, text: str) -> str:
163
+ # This regular expression matches <<vision:...:>> patterns to strip them from output text.
164
+ pattern = r"<<vision:(.*?):>>"
165
+ return re.sub(pattern, "", text)
166
+
167
+ def _build_system_content(self, argument, image_files: list[str]) -> str:
168
+ sections: list[str] = []
169
+ sections.extend(self._verbose_section(argument))
170
+ sections.extend(self._response_format_section(argument))
171
+ sections.extend(self._context_sections(argument))
172
+ sections.extend(self._payload_section(argument))
173
+ sections.extend(self._examples_section(argument))
174
+ sections.extend(self._instruction_section(argument, image_files))
175
+ sections.extend(self._template_suffix_section(argument))
176
+ return "".join(sections)
177
+
178
+ def _verbose_section(self, argument) -> list[str]:
179
+ if argument.prop.suppress_verbose_output:
180
+ return [_NON_VERBOSE_OUTPUT]
181
+ return []
182
+
183
+ def _response_format_section(self, argument) -> list[str]:
184
+ if (
185
+ argument.prop.response_format
186
+ and argument.prop.response_format.get("type") == "json_object"
187
+ ):
188
+ return ["<RESPONSE_FORMAT/>\nYou are a helpful assistant designed to output JSON.\n\n"]
189
+ return []
190
+
191
+ def _context_sections(self, argument) -> list[str]:
192
+ sections: list[str] = []
193
+ static_ctxt, dyn_ctxt = argument.prop.instance.global_context
194
+ if len(static_ctxt) > 0:
195
+ sections.append(f"<STATIC CONTEXT/>\n{static_ctxt}\n\n")
196
+ if len(dyn_ctxt) > 0:
197
+ sections.append(f"<DYNAMIC CONTEXT/>\n{dyn_ctxt}\n\n")
198
+ return sections
199
+
200
+ def _payload_section(self, argument) -> list[str]:
201
+ if argument.prop.payload:
202
+ return [f"<ADDITIONAL CONTEXT/>\n{argument.prop.payload!s}\n\n"]
203
+ return []
204
+
205
+ def _examples_section(self, argument) -> list[str]:
206
+ examples = argument.prop.examples
207
+ if examples and len(examples) > 0:
208
+ return [f"<EXAMPLES/>\n{examples!s}\n\n"]
209
+ return []
210
+
211
+ def _instruction_section(self, argument, image_files: list[str]) -> list[str]:
212
+ if argument.prop.prompt is None or len(argument.prop.prompt) == 0:
213
+ return []
214
+ val = str(argument.prop.prompt)
215
+ if len(image_files) > 0:
216
+ val = self._remove_vision_pattern(val)
217
+ return [f"<INSTRUCTION/>\n{val}\n\n"]
218
+
219
+ def _template_suffix_section(self, argument) -> list[str]:
220
+ if argument.prop.template_suffix:
221
+ return [
222
+ f" You will only generate content for the placeholder `{argument.prop.template_suffix!s}` "
223
+ "following the instructions and the provided context information.\n\n"
224
+ ]
225
+ return []
226
+
227
+ def _build_user_text(self, argument, image_files: list[str]) -> str:
228
+ suffix = str(argument.prop.processed_input)
229
+ if len(image_files) > 0:
230
+ suffix = self._remove_vision_pattern(suffix)
231
+ return suffix
232
+
233
+ def _create_user_message(self, user_text: str, image_files: list[str]) -> dict:
234
+ if image_files:
235
+ images = [{"type": "input_image", "image_url": f} for f in image_files]
236
+ return {"role": "user", "content": [*images, {"type": "input_text", "text": user_text}]}
237
+ return {"role": "user", "content": user_text}
238
+
239
+ def _apply_self_prompt_if_needed(
240
+ self, argument, system: str, user_msg: dict, user_text: str, image_files: list[str]
241
+ ) -> tuple[str, dict]:
242
+ if not (
243
+ argument.prop.instance._kwargs.get("self_prompt", False) or argument.prop.self_prompt
244
+ ):
245
+ return system, user_msg
246
+ self_prompter = SelfPrompt()
247
+ key = "developer" if self._is_reasoning_model() else "system"
248
+ res = self_prompter({"user": user_text, key: system})
249
+ if res is None:
250
+ UserMessage("Self-prompting failed!", raise_with=ValueError)
251
+ new_user_msg = self._create_user_message(res["user"], image_files)
252
+ return res[key], new_user_msg
253
+
254
+ def _prepare_raw_input(self, argument):
255
+ if not argument.prop.processed_input:
256
+ UserMessage(
257
+ "Need to provide a prompt instruction to the engine if raw_input is enabled.",
258
+ raise_with=ValueError,
259
+ )
260
+ value = argument.prop.processed_input
261
+ if not isinstance(value, list):
262
+ if not isinstance(value, dict):
263
+ value = {"role": "user", "content": str(value)}
264
+ value = [value]
265
+ return value
266
+
267
+ def prepare(self, argument):
268
+ if argument.prop.raw_input:
269
+ argument.prop.prepared_input = self._prepare_raw_input(argument)
270
+ return
271
+
272
+ image_files = self._handle_image_content(str(argument.prop.processed_input))
273
+ system_content = self._build_system_content(argument, image_files)
274
+ user_text = self._build_user_text(argument, image_files)
275
+ user_msg = self._create_user_message(user_text, image_files)
276
+ system_content, user_msg = self._apply_self_prompt_if_needed(
277
+ argument, system_content, user_msg, user_text, image_files
278
+ )
279
+
280
+ role = "developer" if self._is_reasoning_model() else "system"
281
+ argument.prop.prepared_input = [
282
+ {"role": role, "content": system_content},
283
+ user_msg,
284
+ ]
285
+
286
+ def _prepare_request_payload(self, messages, argument) -> dict:
287
+ kwargs = argument.kwargs
288
+ max_tokens = kwargs.get("max_tokens")
289
+ max_output_tokens = kwargs.get("max_output_tokens")
290
+ remaining_tokens = self.compute_remaining_tokens(messages)
291
+
292
+ if max_tokens is not None:
293
+ UserMessage(
294
+ "'max_tokens' is deprecated in favor of 'max_output_tokens' for Responses API."
295
+ )
296
+ if max_tokens > self.max_response_tokens:
297
+ max_output_tokens = remaining_tokens
298
+ else:
299
+ max_output_tokens = max_tokens
300
+
301
+ if max_output_tokens is not None and max_output_tokens > self.max_response_tokens:
302
+ UserMessage(
303
+ f"Provided 'max_output_tokens' ({max_output_tokens}) exceeds max ({self.max_response_tokens}). "
304
+ f"Truncating to {remaining_tokens}."
305
+ )
306
+ max_output_tokens = remaining_tokens
307
+
308
+ payload: dict = {
309
+ "model": kwargs.get("model", self.model),
310
+ "input": messages,
311
+ }
312
+
313
+ if max_output_tokens is not None:
314
+ payload["max_output_tokens"] = max_output_tokens
315
+
316
+ if kwargs.get("temperature") is not None and not self._is_reasoning_model():
317
+ payload["temperature"] = kwargs["temperature"]
318
+ if kwargs.get("top_p") is not None and not self._is_reasoning_model():
319
+ payload["top_p"] = kwargs["top_p"]
320
+
321
+ if self._is_reasoning_model():
322
+ if self.model == "gpt-5-pro":
323
+ reasoning = {"effort": "high"}
324
+ else:
325
+ reasoning = kwargs.get("reasoning", {"effort": "medium"})
326
+ payload["reasoning"] = reasoning
327
+
328
+ tools = kwargs.get("tools")
329
+ if tools:
330
+ payload["tools"] = self._convert_tools(tools)
331
+ tool_choice = kwargs.get("tool_choice", "auto")
332
+ payload["tool_choice"] = tool_choice
333
+
334
+ if kwargs.get("response_format"):
335
+ payload["text"] = {"format": kwargs["response_format"]}
336
+
337
+ return payload
338
+
339
+ def _convert_tools(self, tools: list) -> list:
340
+ converted = []
341
+ for tool in tools:
342
+ if tool.get("type") == "function":
343
+ converted.append(
344
+ {
345
+ "type": "function",
346
+ "name": tool.get("name") or tool.get("function", {}).get("name"),
347
+ "description": tool.get("description")
348
+ or tool.get("function", {}).get("description"),
349
+ "parameters": tool.get("parameters")
350
+ or tool.get("function", {}).get("parameters"),
351
+ }
352
+ )
353
+ else:
354
+ converted.append(tool)
355
+ return converted
356
+
357
+ def _extract_output_text(self, response) -> list[str]:
358
+ outputs: list[str] = []
359
+ for output in response.output or []:
360
+ if output.type == "message" and output.content:
361
+ for content in output.content:
362
+ if hasattr(content, "text"):
363
+ outputs.append(content.text)
364
+ if not outputs and hasattr(response, "output_text") and response.output_text:
365
+ outputs.append(response.output_text)
366
+ return outputs
367
+
368
+ def _process_function_calls(self, response, metadata: dict) -> dict:
369
+ for output in response.output or []:
370
+ if output.type == "function_call":
371
+ try:
372
+ args_dict = json.loads(output.arguments)
373
+ except json.JSONDecodeError:
374
+ args_dict = {}
375
+ metadata["function_call"] = {
376
+ "name": output.name,
377
+ "arguments": args_dict,
378
+ "call_id": output.call_id,
379
+ }
380
+ break
381
+ return metadata
382
+
383
+ def _extract_thinking(self, response) -> str | None:
384
+ if not self._is_reasoning_model():
385
+ return None
386
+ for output in response.output or []:
387
+ if output.type == "reasoning" and hasattr(output, "summary") and output.summary:
388
+ texts = [s.text for s in output.summary if hasattr(s, "text") and s.text]
389
+ if texts:
390
+ return "\n".join(texts)
391
+ return None
392
+
393
+ def forward(self, argument):
394
+ kwargs = argument.kwargs
395
+ messages = argument.prop.prepared_input
396
+ payload = self._prepare_request_payload(messages, argument)
397
+ except_remedy = kwargs.get("except_remedy")
398
+
399
+ try:
400
+ res = self.client.responses.create(**payload)
401
+ except Exception as e:
402
+ if openai.api_key is None or openai.api_key == "":
403
+ msg = "OpenAI API key is not set."
404
+ UserMessage(msg)
405
+ if (
406
+ self.config["NEUROSYMBOLIC_ENGINE_API_KEY"] is None
407
+ or self.config["NEUROSYMBOLIC_ENGINE_API_KEY"] == ""
408
+ ):
409
+ UserMessage(msg, raise_with=ValueError)
410
+ openai.api_key = self.config["NEUROSYMBOLIC_ENGINE_API_KEY"]
411
+
412
+ callback = self.client.responses.create
413
+ if except_remedy is not None:
414
+ res = except_remedy(self, e, callback, argument)
415
+ else:
416
+ UserMessage(f"Error during generation. Caused by: {e}", raise_with=ValueError)
417
+
418
+ metadata = {"raw_output": res}
419
+ if payload.get("tools"):
420
+ metadata = self._process_function_calls(res, metadata)
421
+
422
+ thinking = self._extract_thinking(res)
423
+ if thinking:
424
+ metadata["thinking"] = thinking
425
+
426
+ output = self._extract_output_text(res)
427
+ if not output and "function_call" in metadata:
428
+ output = [""]
429
+ return output, metadata
@@ -298,6 +298,7 @@ class ExtractResult(Result):
298
298
 
299
299
  class ParallelEngine(Engine):
300
300
  MAX_INCLUDE_DOMAINS = 10
301
+ MAX_EXCLUDE_DOMAINS = 10
301
302
 
302
303
  def __init__(self, api_key: str | None = None):
303
304
  super().__init__()
@@ -350,7 +351,6 @@ class ParallelEngine(Engine):
350
351
  if not netloc or netloc in seen:
351
352
  continue
352
353
  if not self._is_valid_domain(netloc):
353
- # Skip strings that are not apex domains or bare TLD patterns
354
354
  continue
355
355
  seen.add(netloc)
356
356
  out.append(netloc)
@@ -358,6 +358,23 @@ class ParallelEngine(Engine):
358
358
  break
359
359
  return out
360
360
 
361
+ def _normalize_exclude_domains(self, domains: list[str] | None) -> list[str]:
362
+ if not isinstance(domains, list):
363
+ return []
364
+ seen: set[str] = set()
365
+ out: list[str] = []
366
+ for d in domains:
367
+ netloc = self._extract_netloc(d)
368
+ if not netloc or netloc in seen:
369
+ continue
370
+ if not self._is_valid_domain(netloc):
371
+ continue
372
+ seen.add(netloc)
373
+ out.append(netloc)
374
+ if len(out) >= self.MAX_EXCLUDE_DOMAINS:
375
+ break
376
+ return out
377
+
361
378
  def _coerce_search_queries(self, value: Any) -> list[str]:
362
379
  if value is None:
363
380
  return []
@@ -411,7 +428,14 @@ class ParallelEngine(Engine):
411
428
  max_chars_per_result = kwargs.get("max_chars_per_result", 15000)
412
429
  excerpts = {"max_chars_per_result": max_chars_per_result}
413
430
  include = self._normalize_include_domains(kwargs.get("allowed_domains"))
414
- source_policy = {"include_domains": include} if include else None
431
+ exclude = self._normalize_exclude_domains(kwargs.get("excluded_domains"))
432
+ source_policy: dict[str, Any] | None = None
433
+ if include or exclude:
434
+ source_policy = {}
435
+ if include:
436
+ source_policy["include_domains"] = include
437
+ if exclude:
438
+ source_policy["exclude_domains"] = exclude
415
439
  objective = kwargs.get("objective")
416
440
 
417
441
  try:
@@ -432,7 +456,14 @@ class ParallelEngine(Engine):
432
456
  task_input = self._compose_task_input(queries)
433
457
 
434
458
  include = self._normalize_include_domains(kwargs.get("allowed_domains"))
435
- source_policy = {"include_domains": include} if include else None
459
+ exclude = self._normalize_exclude_domains(kwargs.get("excluded_domains"))
460
+ source_policy: dict[str, Any] | None = None
461
+ if include or exclude:
462
+ source_policy = {}
463
+ if include:
464
+ source_policy["include_domains"] = include
465
+ if exclude:
466
+ source_policy["exclude_domains"] = exclude
436
467
  metadata = self._coerce_metadata(kwargs.get("metadata"))
437
468
 
438
469
  output_schema = (
@@ -10,6 +10,7 @@ from .groq import SUPPORTED_CHAT_MODELS as GROQ_CHAT_MODELS
10
10
  from .groq import SUPPORTED_REASONING_MODELS as GROQ_REASONING_MODELS
11
11
  from .openai import SUPPORTED_CHAT_MODELS as OPENAI_CHAT_MODELS
12
12
  from .openai import SUPPORTED_REASONING_MODELS as OPENAI_REASONING_MODELS
13
+ from .openai import SUPPORTED_RESPONSES_MODELS as OPENAI_RESPONSES_MODELS
13
14
 
14
15
  __all__ = [
15
16
  "ANTHROPIC_CHAT_MODELS",
@@ -24,4 +25,5 @@ __all__ = [
24
25
  "GROQ_REASONING_MODELS",
25
26
  "OPENAI_CHAT_MODELS",
26
27
  "OPENAI_REASONING_MODELS",
28
+ "OPENAI_RESPONSES_MODELS",
27
29
  ]
@@ -10,6 +10,7 @@ SUPPORTED_CHAT_MODELS = [
10
10
  "claude-3-haiku-20240307",
11
11
  ]
12
12
  SUPPORTED_REASONING_MODELS = [
13
+ "claude-opus-4-5",
13
14
  "claude-opus-4-1",
14
15
  "claude-opus-4-0",
15
16
  "claude-sonnet-4-0",
@@ -22,7 +23,8 @@ SUPPORTED_REASONING_MODELS = [
22
23
  class AnthropicMixin:
23
24
  def api_max_context_tokens(self):
24
25
  if (
25
- self.model == "claude-opus-4-1"
26
+ self.model == "claude-opus-4-5"
27
+ or self.model == "claude-opus-4-1"
26
28
  or self.model == "claude-opus-4-0"
27
29
  or self.model == "claude-sonnet-4-0"
28
30
  or self.model == "claude-3-7-sonnet-latest"
@@ -41,7 +43,8 @@ class AnthropicMixin:
41
43
 
42
44
  def api_max_response_tokens(self):
43
45
  if (
44
- self.model == "claude-sonnet-4-0"
46
+ self.model == "claude-opus-4-5"
47
+ or self.model == "claude-sonnet-4-0"
45
48
  or self.model == "claude-3-7-sonnet-latest"
46
49
  or self.model == "claude-haiku-4-5"
47
50
  or self.model == "claude-sonnet-4-5"
@@ -38,6 +38,9 @@ SUPPORTED_EMBEDDING_MODELS = [
38
38
  "text-embedding-3-small",
39
39
  "text-embedding-3-large",
40
40
  ]
41
+ SUPPORTED_RESPONSES_MODELS = [
42
+ f"responses:{m}" for m in SUPPORTED_CHAT_MODELS + SUPPORTED_REASONING_MODELS
43
+ ] + ["responses:gpt-5-pro", "responses:o3-pro"]
41
44
 
42
45
 
43
46
  class OpenAIMixin:
@@ -89,6 +92,7 @@ class OpenAIMixin:
89
92
  self.model == "o1"
90
93
  or self.model == "o3"
91
94
  or self.model == "o3-mini"
95
+ or self.model == "o3-pro"
92
96
  or self.model == "o4-mini"
93
97
  or self.model == "gpt-5-chat-latest"
94
98
  or self.model == "gpt-5.1-chat-latest"
@@ -99,6 +103,7 @@ class OpenAIMixin:
99
103
  or self.model == "gpt-5.1"
100
104
  or self.model == "gpt-5-mini"
101
105
  or self.model == "gpt-5-nano"
106
+ or self.model == "gpt-5-pro"
102
107
  ):
103
108
  return 400_000
104
109
  if self.model == "gpt-4.1" or self.model == "gpt-4.1-mini" or self.model == "gpt-4.1-nano":
@@ -138,6 +143,7 @@ class OpenAIMixin:
138
143
  self.model == "o1"
139
144
  or self.model == "o3"
140
145
  or self.model == "o3-mini"
146
+ or self.model == "o3-pro"
141
147
  or self.model == "o4-mini"
142
148
  ):
143
149
  return 100_000
@@ -148,6 +154,8 @@ class OpenAIMixin:
148
154
  or self.model == "gpt-5-nano"
149
155
  ):
150
156
  return 128_000
157
+ if self.model == "gpt-5-pro":
158
+ return 272_000
151
159
  msg = f"Unsupported model: {self.model}"
152
160
  UserMessage(msg)
153
161
  raise ValueError(msg)
symai/components.py CHANGED
@@ -1282,6 +1282,7 @@ class MetadataTracker(Expression):
1282
1282
  token_details[(engine_name, None)]["completion_breakdown"][
1283
1283
  "reasoning_tokens"
1284
1284
  ] += 0
1285
+ self._track_parallel_usage_items(token_details, engine_name, metadata)
1285
1286
  elif engine_name in ("GPTXChatEngine", "GPTXReasoningEngine"):
1286
1287
  usage = metadata["raw_output"].usage
1287
1288
  token_details[(engine_name, model_name)]["usage"]["completion_tokens"] += (
@@ -1312,7 +1313,7 @@ class MetadataTracker(Expression):
1312
1313
  token_details[(engine_name, model_name)]["prompt_breakdown"][
1313
1314
  "cached_tokens"
1314
1315
  ] += usage.prompt_tokens_details.cached_tokens
1315
- elif engine_name == "GPTXSearchEngine":
1316
+ elif engine_name in ("GPTXSearchEngine", "OpenAIResponsesEngine"):
1316
1317
  usage = metadata["raw_output"].usage
1317
1318
  token_details[(engine_name, model_name)]["usage"]["prompt_tokens"] += (
1318
1319
  usage.input_tokens
@@ -1364,6 +1365,19 @@ class MetadataTracker(Expression):
1364
1365
  supported_engines = ("GPTXChatEngine", "GPTXReasoningEngine", "GPTXSearchEngine")
1365
1366
  return engine_name in supported_engines
1366
1367
 
1368
+ def _track_parallel_usage_items(self, token_details, engine_name, metadata):
1369
+ usage_items = getattr(metadata.get("raw_output", None), "usage", None)
1370
+ if not usage_items:
1371
+ return
1372
+ if isinstance(usage_items, dict):
1373
+ usage_items = usage_items.values()
1374
+ extras = token_details[(engine_name, None)].setdefault("extras", {})
1375
+ for item in usage_items:
1376
+ name = getattr(item, "name", None)
1377
+ count = getattr(item, "count", None)
1378
+ if name in ("sku_search", "sku_extract_excerpts") and isinstance(count, (int, float)):
1379
+ extras[name] = extras.get(name, 0) + count
1380
+
1367
1381
  def _accumulate_time_field(self, accumulated: dict, metadata: dict) -> None:
1368
1382
  if "time" in metadata and "time" in accumulated:
1369
1383
  accumulated["time"] += metadata["time"]
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import os
2
3
  import subprocess
3
4
  import sys
4
5
  from pathlib import Path
@@ -41,6 +42,13 @@ def qdrant_server(): # noqa
41
42
  default="./qdrant_storage",
42
43
  help="Path to Qdrant storage directory (default: ./qdrant_storage)",
43
44
  )
45
+ parser.add_argument(
46
+ "--use-env-storage",
47
+ action="store_true",
48
+ default=False,
49
+ help="Use QDRANT__STORAGE__STORAGE_PATH environment variable instead of passing --storage-path. "
50
+ "If set, storage path argument/volume mount will be skipped, allowing Qdrant to use its own defaults or env vars.",
51
+ )
44
52
  parser.add_argument(
45
53
  "--config-path", type=str, default=None, help="Path to Qdrant configuration file"
46
54
  )
@@ -62,6 +70,12 @@ def qdrant_server(): # noqa
62
70
  default=False,
63
71
  help="Run Docker container in detached mode (default: False)",
64
72
  )
73
+ parser.add_argument(
74
+ "--no-cache",
75
+ action="store_true",
76
+ default=False,
77
+ help="Disable caching in Qdrant server (default: False)",
78
+ )
65
79
 
66
80
  main_args, qdrant_args = parser.parse_known_args()
67
81
 
@@ -92,15 +106,19 @@ def qdrant_server(): # noqa
92
106
  # Build command for binary execution
93
107
  command = [main_args.binary_path]
94
108
 
95
- # Ensure storage directory exists
96
- storage_path = Path(main_args.storage_path)
97
- storage_path.mkdir(parents=True, exist_ok=True)
98
- abs_storage_path = str(storage_path.resolve())
99
-
100
- # Add standard Qdrant arguments
101
- # Set storage path via environment variable or command argument
102
- # Qdrant binary accepts --storage-path argument
103
- command.extend(["--storage-path", abs_storage_path])
109
+ # Add storage path argument unless --use-env-storage is set
110
+ if not main_args.use_env_storage:
111
+ # Ensure storage directory exists
112
+ storage_path = Path(main_args.storage_path)
113
+ storage_path.mkdir(parents=True, exist_ok=True)
114
+ abs_storage_path = str(storage_path.resolve())
115
+ # Qdrant binary accepts --storage-path argument
116
+ command.extend(["--storage-path", abs_storage_path])
117
+ elif os.getenv("QDRANT__STORAGE__STORAGE_PATH"):
118
+ # If using env storage and env var is set, pass it through
119
+ # Note: Qdrant binary may read this from env, but we can also pass it explicitly
120
+ abs_storage_path = os.getenv("QDRANT__STORAGE__STORAGE_PATH")
121
+ command.extend(["--storage-path", abs_storage_path])
104
122
 
105
123
  # Add host, port, and grpc-port arguments
106
124
  command.extend(["--host", main_args.host])
@@ -110,15 +128,16 @@ def qdrant_server(): # noqa
110
128
  if main_args.config_path:
111
129
  command.extend(["--config-path", main_args.config_path])
112
130
 
131
+ # Add no-cache environment variable if flag is set
132
+ if main_args.no_cache:
133
+ # Set environment variable to disable caching
134
+ # Qdrant uses environment variables with QDRANT__ prefix
135
+ os.environ["QDRANT__SERVICE__ENABLE_STATIC_CONTENT_CACHE"] = "false"
136
+
113
137
  # Add any additional Qdrant-specific arguments
114
138
  command.extend(qdrant_args)
115
139
 
116
140
  else: # docker
117
- # Ensure storage directory exists
118
- storage_path = Path(main_args.storage_path)
119
- storage_path.mkdir(parents=True, exist_ok=True)
120
- abs_storage_path = str(storage_path.resolve())
121
-
122
141
  # Build Docker command
123
142
  command = ["docker", "run"]
124
143
 
@@ -138,8 +157,20 @@ def qdrant_server(): # noqa
138
157
  command.extend(["-p", f"{main_args.port}:6333"])
139
158
  command.extend(["-p", f"{main_args.grpc_port}:6334"])
140
159
 
141
- # Volume mount for storage
142
- command.extend(["-v", f"{abs_storage_path}:/qdrant/storage:z"])
160
+ # Volume mount for storage (skip if --use-env-storage is set)
161
+ if not main_args.use_env_storage:
162
+ # Ensure storage directory exists
163
+ storage_path = Path(main_args.storage_path)
164
+ storage_path.mkdir(parents=True, exist_ok=True)
165
+ abs_storage_path = str(storage_path.resolve())
166
+ # Volume mount for storage
167
+ command.extend(["-v", f"{abs_storage_path}:/qdrant/storage:z"])
168
+ # Set storage path environment variable to use the mounted volume
169
+ command.extend(["-e", "QDRANT__STORAGE__STORAGE_PATH=/qdrant/storage"])
170
+ elif os.getenv("QDRANT__STORAGE__STORAGE_PATH"):
171
+ # If using env storage and env var is set, pass it through to container
172
+ env_storage_path = os.getenv("QDRANT__STORAGE__STORAGE_PATH")
173
+ command.extend(["-e", f"QDRANT__STORAGE__STORAGE_PATH={env_storage_path}"])
143
174
 
144
175
  # Volume mount for config (if provided)
145
176
  # Note: Qdrant Docker image accepts environment variables and config files
@@ -151,8 +182,10 @@ def qdrant_server(): # noqa
151
182
  command.extend(["-v", f"{config_dir}:/qdrant/config:z"])
152
183
  # Qdrant looks for config.yaml in /qdrant/config by default
153
184
 
154
- # Set storage path environment variable to use the mounted volume
155
- command.extend(["-e", "QDRANT__STORAGE__STORAGE_PATH=/qdrant/storage"])
185
+ # Add no-cache environment variable if flag is set
186
+ if main_args.no_cache:
187
+ # Set environment variable to disable caching in Docker container
188
+ command.extend(["-e", "QDRANT__SERVICE__ENABLE_STATIC_CONTENT_CACHE=false"])
156
189
 
157
190
  # Docker image
158
191
  command.append(main_args.docker_image)
@@ -176,15 +209,20 @@ def qdrant_server(): # noqa
176
209
  str(main_args.port),
177
210
  "--grpc-port",
178
211
  str(main_args.grpc_port),
179
- "--storage-path",
180
- main_args.storage_path,
181
212
  "--docker-image",
182
213
  main_args.docker_image,
183
214
  "--docker-container-name",
184
215
  main_args.docker_container_name,
185
216
  ]
217
+ # Only include storage-path in config if not using env storage
218
+ if not main_args.use_env_storage:
219
+ config_args.extend(["--storage-path", main_args.storage_path])
220
+ else:
221
+ config_args.append("--use-env-storage")
186
222
  if main_args.config_path:
187
223
  config_args.extend(["--config-path", main_args.config_path])
224
+ if main_args.no_cache:
225
+ config_args.append("--no-cache")
188
226
  else:
189
227
  config_args = [
190
228
  "--env",
@@ -197,10 +235,15 @@ def qdrant_server(): # noqa
197
235
  str(main_args.port),
198
236
  "--grpc-port",
199
237
  str(main_args.grpc_port),
200
- "--storage-path",
201
- main_args.storage_path,
202
238
  ]
239
+ # Only include storage-path in config if not using env storage
240
+ if not main_args.use_env_storage:
241
+ config_args.extend(["--storage-path", main_args.storage_path])
242
+ else:
243
+ config_args.append("--use-env-storage")
203
244
  if main_args.config_path:
204
245
  config_args.extend(["--config-path", main_args.config_path])
246
+ if main_args.no_cache:
247
+ config_args.append("--no-cache")
205
248
 
206
249
  return command, config_args
symai/utils.py CHANGED
@@ -4,9 +4,9 @@ import base64
4
4
  import inspect
5
5
  import os
6
6
  import warnings
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
8
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING
9
+ from typing import TYPE_CHECKING, Any
10
10
 
11
11
  import cv2
12
12
  import httpx
@@ -217,6 +217,7 @@ class RuntimeInfo:
217
217
  total_calls: int
218
218
  total_tokens: int
219
219
  cost_estimate: float
220
+ extras: dict[str, Any] = field(default_factory=dict)
220
221
 
221
222
  def __add__(self, other):
222
223
  add_elapsed_time = other.total_elapsed_time if hasattr(other, "total_elapsed_time") else 0
@@ -229,6 +230,17 @@ class RuntimeInfo:
229
230
  add_cached_tokens = other.cached_tokens if hasattr(other, "cached_tokens") else 0
230
231
  add_reasoning_tokens = other.reasoning_tokens if hasattr(other, "reasoning_tokens") else 0
231
232
  add_total_calls = other.total_calls if hasattr(other, "total_calls") else 0
233
+ extras = other.extras if hasattr(other, "extras") else {}
234
+ merged_extras = {**(self.extras or {})}
235
+ for key, value in (extras or {}).items():
236
+ if (
237
+ key in merged_extras
238
+ and isinstance(merged_extras[key], (int, float))
239
+ and isinstance(value, (int, float))
240
+ ):
241
+ merged_extras[key] += value
242
+ else:
243
+ merged_extras[key] = value
232
244
 
233
245
  return RuntimeInfo(
234
246
  total_elapsed_time=self.total_elapsed_time + add_elapsed_time,
@@ -239,6 +251,7 @@ class RuntimeInfo:
239
251
  total_calls=self.total_calls + add_total_calls,
240
252
  total_tokens=self.total_tokens + add_total_tokens,
241
253
  cost_estimate=self.cost_estimate + add_cost_estimate,
254
+ extras=merged_extras,
242
255
  )
243
256
 
244
257
  @staticmethod
@@ -248,7 +261,7 @@ class RuntimeInfo:
248
261
  return RuntimeInfo.from_usage_stats(tracker.usage, total_elapsed_time)
249
262
  except Exception as e:
250
263
  UserMessage(f"Failed to parse metadata: {e}", raise_with=ValueError)
251
- return RuntimeInfo(0, 0, 0, 0, 0, 0, 0, 0)
264
+ return RuntimeInfo(0, 0, 0, 0, 0, 0, 0, 0, {})
252
265
 
253
266
  @staticmethod
254
267
  def from_usage_stats(usage_stats: dict | None, total_elapsed_time: float = 0):
@@ -266,9 +279,10 @@ class RuntimeInfo:
266
279
  total_calls=data_box.usage.total_calls,
267
280
  total_tokens=data_box.usage.total_tokens,
268
281
  cost_estimate=0, # Placeholder for cost estimate
282
+ extras=data.get("extras", {}),
269
283
  )
270
284
  return usage_per_engine
271
- return RuntimeInfo(0, 0, 0, 0, 0, 0, 0, 0)
285
+ return RuntimeInfo(0, 0, 0, 0, 0, 0, 0, 0, {})
272
286
 
273
287
  @staticmethod
274
288
  def estimate_cost(info: RuntimeInfo, f_pricing: callable, **kwargs) -> RuntimeInfo:
@@ -281,4 +295,5 @@ class RuntimeInfo:
281
295
  total_calls=info.total_calls,
282
296
  total_tokens=info.total_tokens,
283
297
  cost_estimate=f_pricing(info, **kwargs),
298
+ extras=info.extras,
284
299
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: symbolicai
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: A Neurosymbolic Perspective on Large Language Models
5
5
  Author-email: Marius-Constantin Dinu <marius@extensity.ai>, Leoveanu-Condrei Claudiu <leo@extensity.ai>
6
6
  License: BSD 3-Clause License
@@ -1,7 +1,7 @@
1
1
  symai/TERMS_OF_SERVICE.md,sha256=HN42UXVI_wAVDHjMShzy_k7xAsbjXaATNeMKcIte_eg,91409
2
- symai/__init__.py,sha256=KnIPNxN20P6-2mEa3c9mbeCNI9aV7w0_HJEjMZmMOjU,18530
2
+ symai/__init__.py,sha256=irjwVGnXl5w5mBwBTT3Z6HyXKjZNdrjEVYiDUxggVfo,18530
3
3
  symai/chat.py,sha256=DCEbmZ96wv-eitAVt6-oF6PT3JM3cT59Iy3r2Hucd_M,14100
4
- symai/components.py,sha256=iMpuICXkfXKA2j2u__92COQ10BmqNKHH4GginihD614,63906
4
+ symai/components.py,sha256=s10kLvwAOjSBQQohoHGtAIKs0UHHCd_HhiRvMbNtIH0,64685
5
5
  symai/constraints.py,sha256=ljjB9p0qK4DrDl_u5G_Y-Y6WAH5ZHANIqLLxRtwcORs,1980
6
6
  symai/context.py,sha256=4M69MJOeWSdPTr2Y9teoNTs-nEvpzcAcr7900UgORXA,189
7
7
  symai/core.py,sha256=gI9qvTT0Skq2D0izdhAoN3RdwBtWei59KO52mKN1Sos,70420
@@ -20,7 +20,7 @@ symai/shellsv.py,sha256=rwTUcgaNdUm4_SRM7u4aMndMaEAaM6jBvWbEQzWoI0c,39831
20
20
  symai/strategy.py,sha256=BQTXRnBv57fYO47A--WA6KK1oqGmf9Aijm0p4a_vvqY,45004
21
21
  symai/symbol.py,sha256=s5CYwP5SGcRUzZ7TlakZFpKBX_Q0mwPQKRbv4pC3sxM,40443
22
22
  symai/symsh.md,sha256=QwY_-fX0Ge7Aazul0xde2DuF2FZLw_elxrkXR3kuKDQ,1245
23
- symai/utils.py,sha256=oCtrlbOdq9a4cglyKaRNkv5ChprZ7WCqRHNxX0iEyuU,10175
23
+ symai/utils.py,sha256=m4iQzxclkPAUSDderTO_OK2fKznJ69pLfbBcTYq4p70,10824
24
24
  symai/backend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
25
  symai/backend/base.py,sha256=28kIR0NrTL-BgmjpP31JXNlRH4u1TF9qarOXqlCFbVI,7296
26
26
  symai/backend/settings.py,sha256=T5iUAV8aGLnQy9iRTvUDJq49LGobiSHPGV1HqBHjkEg,6181
@@ -41,10 +41,10 @@ symai/backend/engines/files/engine_io.py,sha256=4eYBz44rQYWD7VO6Pn7hVF_cOnqNuolo
41
41
  symai/backend/engines/imagecaptioning/engine_blip2.py,sha256=8lTzc8sQpuNY4AUb_ZweRKr95v-sFtTykT5ennVf6g0,2915
42
42
  symai/backend/engines/imagecaptioning/engine_llavacpp_client.py,sha256=jBsLZv0Laa4tuPyX0VQ7uwyldyO3aYIbbj73WjTbceM,6793
43
43
  symai/backend/engines/index/engine_pinecone.py,sha256=fxCew1ldUdjd9UtqnMuWFDiVz5X5BUIKZtq1iSDhj28,9132
44
- symai/backend/engines/index/engine_qdrant.py,sha256=f2lguAgCTZz2p6UI__u7puxH-X9UteWxBIzQnOUX1Xk,37748
44
+ symai/backend/engines/index/engine_qdrant.py,sha256=GtWVbgaqJuATfGus0A0h7EgM_8hKlbw3fnorNJmbC_Q,43300
45
45
  symai/backend/engines/index/engine_vectordb.py,sha256=xXU8QaC2BX9O4dDjDCVYgWO4PxQMpmNlhtal6UVtV0o,8541
46
46
  symai/backend/engines/lean/engine_lean4.py,sha256=ln5nbQn5szq8nRulbREPLCPQ5bwjM_A5XAGMkfzPdT8,10102
47
- symai/backend/engines/neurosymbolic/__init__.py,sha256=txvvXueinXrY1wq2Fp9xYqb5eLbkxXIJ82whhphKqyE,2062
47
+ symai/backend/engines/neurosymbolic/__init__.py,sha256=o7HUmxcYSrIkutGYB-6_Qur3adHyrkVeWroDtqEK-YE,2279
48
48
  symai/backend/engines/neurosymbolic/engine_anthropic_claudeX_chat.py,sha256=WyuskobMjf9ynxRWUnXk55DUMUN7qv7jT1nbZP3Bx9o,21054
49
49
  symai/backend/engines/neurosymbolic/engine_anthropic_claudeX_reasoning.py,sha256=thEPDh8H-5XrsADF0mVTWB6m_dJgKeTT49HvyCGJcQM,21291
50
50
  symai/backend/engines/neurosymbolic/engine_cerebras.py,sha256=ki84Qh7hdxaKn--UgMMUvAEoqJos7VeKtkka6XpHI3g,13336
@@ -56,11 +56,12 @@ symai/backend/engines/neurosymbolic/engine_llama_cpp.py,sha256=CWy1aqqV-NFey0b9s
56
56
  symai/backend/engines/neurosymbolic/engine_openai_gptX_chat.py,sha256=FfNkesV64d3gf7MWKf2PoK5nUjetS2MndTbWhE1KieE,28267
57
57
  symai/backend/engines/neurosymbolic/engine_openai_gptX_completion.py,sha256=YgxRoitmDz2de_W7rkhVXYEkDqTJQlgxK4f8tWlt88Q,13840
58
58
  symai/backend/engines/neurosymbolic/engine_openai_gptX_reasoning.py,sha256=yWiCT_jHHQGs2_YqRhSRvVFxQFXGb1TwvXf2zZWoStU,27171
59
+ symai/backend/engines/neurosymbolic/engine_openai_responses.py,sha256=OSqXJIMUWa4BANUhfxZg9lGmO7FE6xM0G_w0aRqODSw,17751
59
60
  symai/backend/engines/ocr/engine_apilayer.py,sha256=UpC3oHBdSM6wlPVqxwMkemBd-Y0ReVwc270O_EVbRD0,2267
60
61
  symai/backend/engines/output/engine_stdout.py,sha256=BWNXACl5U-WYIJnT1pZNwZsTRMzP1XzA0A7o693mmyQ,899
61
62
  symai/backend/engines/scrape/engine_requests.py,sha256=yyVFT9JrZ4S6v5U_cykef-tn5iWGl1MAdpqnDaQ70TA,13821
62
63
  symai/backend/engines/search/engine_openai.py,sha256=hAEu3vPZzLTvgmNc4BSZDTcNb4ek4xYeOf8xgti2zRs,14248
63
- symai/backend/engines/search/engine_parallel.py,sha256=3kqVpbLyh7TXNBFP63A14Akfx8kEu0n0zPjqtf7Ere8,25319
64
+ symai/backend/engines/search/engine_parallel.py,sha256=PybgfkpJ_rA5FkVebZisfXwWIcki2AJPxqZfnWPl5To,26422
64
65
  symai/backend/engines/search/engine_perplexity.py,sha256=rXnZjMCSiIRuJcNSchE58-f9zWJmYpkKMHONF_XwGnk,4100
65
66
  symai/backend/engines/search/engine_serpapi.py,sha256=ZJJBnEDoLjkpxWt_o4vFZanwqojH8ZFBWmWNnEaIbww,3618
66
67
  symai/backend/engines/speech_to_text/engine_local_whisper.py,sha256=EOUh2GCeEhZ2Av72i_AZ4NSj9e46Pl7Ft6sIErFy6FI,8387
@@ -68,13 +69,13 @@ symai/backend/engines/symbolic/engine_wolframalpha.py,sha256=mTH0N4rA0gMffSBLjf2
68
69
  symai/backend/engines/text_to_speech/engine_openai.py,sha256=AtY0mDvIM_yZQ6AgYNXuyinZr_OaMK7XiPLQ6fe6RBo,2013
69
70
  symai/backend/engines/text_vision/engine_clip.py,sha256=hU9vsHtKPpQYEoESyjuGXOzMhUNhvspYMCNkCAqn2x8,3648
70
71
  symai/backend/engines/userinput/engine_console.py,sha256=fDO6PRQI3NYZ_nHVXDFIsS9cFDRv3aTOfv8h5a360jc,743
71
- symai/backend/mixin/__init__.py,sha256=KamwgTNRE63ro-uy5QGmPWkmSMKNxm6pRiRfG_LSic0,1215
72
- symai/backend/mixin/anthropic.py,sha256=kTHJnFlmgqN6X5fjsAnwSwZ6Qdhr-Zo5vI46_D9Ke3Y,2286
72
+ symai/backend/mixin/__init__.py,sha256=rJjz7OSR2Qp_gl9KCL6ILuUh1BduKRPLSiWYIQuBIv4,1320
73
+ symai/backend/mixin/anthropic.py,sha256=GdHimGqiJcA21Jo797ZEeFzotRpCOJdBJQIChl_6NJI,2403
73
74
  symai/backend/mixin/cerebras.py,sha256=MEc9vQ6G4KWWrt0NFjdt2y0rojhtBidwa_n4M8Z5EKI,215
74
75
  symai/backend/mixin/deepseek.py,sha256=7TnyqXQb2t6r6-hzOClPzxfO2d7TShYC989Lmn_YTzM,414
75
76
  symai/backend/mixin/google.py,sha256=N1xxrrTcQkcKJtdPbRorev6dfJ1F65I5XavrGR06GN4,494
76
77
  symai/backend/mixin/groq.py,sha256=at6yFLa35Js8o7D8p_-Y4NjOPJI-lH8yx6tsCDrEy6M,227
77
- symai/backend/mixin/openai.py,sha256=y-QutoEQc3JwM9OIx43JBCB2HaTC1t8nOsA3uEoTImc,5135
78
+ symai/backend/mixin/openai.py,sha256=Skwn3JnXtrH0TWSJbojkMBpSkCEvtD4FesmPY6KCD70,5477
78
79
  symai/collect/__init__.py,sha256=YD1UQoD4Z-_AodqTp48Vv-3UHYUa1g4lZnhm2AsjCd0,202
79
80
  symai/collect/dynamic.py,sha256=72oEdshjue3t_Zs_3D08bhHPKN5mKAw0HEucWAFlqVI,3833
80
81
  symai/collect/pipeline.py,sha256=eyxqqNpa1P5xEL50WgUZT6Z-MRocuLRBqexkVIqWqv8,5360
@@ -160,10 +161,10 @@ symai/ops/primitives.py,sha256=c0GT8rGL2p7dIL-yNoAydpwSZWx__8Ep8T2jj9Q5Eqw,11636
160
161
  symai/server/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
161
162
  symai/server/huggingface_server.py,sha256=wSAVqFiKQsCu5UB2YYVpxJBhJ7GgQBBfePxNi265yP8,9039
162
163
  symai/server/llama_cpp_server.py,sha256=-WPTNB2cbnwtnpES4AtPM__MCasDKl83jr94JGS9tmI,2144
163
- symai/server/qdrant_server.py,sha256=pOm382ZmPJ2jlJiLIxt_KUJqISOgWiKhs3Ojbp5REOI,7420
164
- symbolicai-1.1.1.dist-info/licenses/LICENSE,sha256=9vRFudlJ1ghVfra5lcCUIYQCqnZSYcBLjLHbGRsrQCs,1505
165
- symbolicai-1.1.1.dist-info/METADATA,sha256=NZ8WBjmA0arIzmZQPnBRE6-SOgDtCvMI6VHOhLzzOGU,23603
166
- symbolicai-1.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
167
- symbolicai-1.1.1.dist-info/entry_points.txt,sha256=JV5sdydIfUZdDF6QBEQHiZHod6XNPjCjpWQrXh7gTAw,261
168
- symbolicai-1.1.1.dist-info/top_level.txt,sha256=bOoIDfpDIvCQtQgXcwVKJvxAKwsxpxo2IL4z92rNJjw,6
169
- symbolicai-1.1.1.dist-info/RECORD,,
164
+ symai/server/qdrant_server.py,sha256=l4r4rz29c7cO1dapXO0LQ4sHW4WF44keuz7j8v5azMc,9854
165
+ symbolicai-1.2.0.dist-info/licenses/LICENSE,sha256=9vRFudlJ1ghVfra5lcCUIYQCqnZSYcBLjLHbGRsrQCs,1505
166
+ symbolicai-1.2.0.dist-info/METADATA,sha256=hm-h6TAae8Otfn9oKVPRMjNyRGrQHSVs99j2Sq_QWik,23603
167
+ symbolicai-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
168
+ symbolicai-1.2.0.dist-info/entry_points.txt,sha256=JV5sdydIfUZdDF6QBEQHiZHod6XNPjCjpWQrXh7gTAw,261
169
+ symbolicai-1.2.0.dist-info/top_level.txt,sha256=bOoIDfpDIvCQtQgXcwVKJvxAKwsxpxo2IL4z92rNJjw,6
170
+ symbolicai-1.2.0.dist-info/RECORD,,