aiqtoolkit 1.2.0.dev0__py3-none-any.whl → 1.2.0rc1__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.

Potentially problematic release.


This version of aiqtoolkit might be problematic. Click here for more details.

Files changed (220) hide show
  1. aiq/agent/base.py +170 -8
  2. aiq/agent/dual_node.py +1 -1
  3. aiq/agent/react_agent/agent.py +146 -112
  4. aiq/agent/react_agent/prompt.py +1 -6
  5. aiq/agent/react_agent/register.py +36 -35
  6. aiq/agent/rewoo_agent/agent.py +36 -35
  7. aiq/agent/rewoo_agent/register.py +2 -2
  8. aiq/agent/tool_calling_agent/agent.py +3 -7
  9. aiq/agent/tool_calling_agent/register.py +1 -1
  10. aiq/authentication/__init__.py +14 -0
  11. aiq/authentication/api_key/__init__.py +14 -0
  12. aiq/authentication/api_key/api_key_auth_provider.py +92 -0
  13. aiq/authentication/api_key/api_key_auth_provider_config.py +124 -0
  14. aiq/authentication/api_key/register.py +26 -0
  15. aiq/authentication/exceptions/__init__.py +14 -0
  16. aiq/authentication/exceptions/api_key_exceptions.py +38 -0
  17. aiq/authentication/exceptions/auth_code_grant_exceptions.py +86 -0
  18. aiq/authentication/exceptions/call_back_exceptions.py +38 -0
  19. aiq/authentication/exceptions/request_exceptions.py +54 -0
  20. aiq/authentication/http_basic_auth/__init__.py +0 -0
  21. aiq/authentication/http_basic_auth/http_basic_auth_provider.py +81 -0
  22. aiq/authentication/http_basic_auth/register.py +30 -0
  23. aiq/authentication/interfaces.py +93 -0
  24. aiq/authentication/oauth2/__init__.py +14 -0
  25. aiq/authentication/oauth2/oauth2_auth_code_flow_provider.py +107 -0
  26. aiq/authentication/oauth2/oauth2_auth_code_flow_provider_config.py +39 -0
  27. aiq/authentication/oauth2/register.py +25 -0
  28. aiq/authentication/register.py +21 -0
  29. aiq/builder/builder.py +64 -2
  30. aiq/builder/component_utils.py +16 -3
  31. aiq/builder/context.py +37 -0
  32. aiq/builder/eval_builder.py +43 -2
  33. aiq/builder/function.py +44 -12
  34. aiq/builder/function_base.py +1 -1
  35. aiq/builder/intermediate_step_manager.py +6 -8
  36. aiq/builder/user_interaction_manager.py +3 -0
  37. aiq/builder/workflow.py +23 -18
  38. aiq/builder/workflow_builder.py +421 -61
  39. aiq/cli/commands/info/list_mcp.py +103 -16
  40. aiq/cli/commands/sizing/__init__.py +14 -0
  41. aiq/cli/commands/sizing/calc.py +294 -0
  42. aiq/cli/commands/sizing/sizing.py +27 -0
  43. aiq/cli/commands/start.py +2 -1
  44. aiq/cli/entrypoint.py +2 -0
  45. aiq/cli/register_workflow.py +80 -0
  46. aiq/cli/type_registry.py +151 -30
  47. aiq/data_models/api_server.py +124 -12
  48. aiq/data_models/authentication.py +231 -0
  49. aiq/data_models/common.py +35 -7
  50. aiq/data_models/component.py +17 -9
  51. aiq/data_models/component_ref.py +33 -0
  52. aiq/data_models/config.py +60 -3
  53. aiq/data_models/dataset_handler.py +2 -1
  54. aiq/data_models/embedder.py +1 -0
  55. aiq/data_models/evaluate.py +23 -0
  56. aiq/data_models/function_dependencies.py +8 -0
  57. aiq/data_models/interactive.py +10 -1
  58. aiq/data_models/intermediate_step.py +38 -5
  59. aiq/data_models/its_strategy.py +30 -0
  60. aiq/data_models/llm.py +1 -0
  61. aiq/data_models/memory.py +1 -0
  62. aiq/data_models/object_store.py +44 -0
  63. aiq/data_models/profiler.py +1 -0
  64. aiq/data_models/retry_mixin.py +35 -0
  65. aiq/data_models/span.py +187 -0
  66. aiq/data_models/telemetry_exporter.py +2 -2
  67. aiq/embedder/nim_embedder.py +2 -1
  68. aiq/embedder/openai_embedder.py +2 -1
  69. aiq/eval/config.py +19 -1
  70. aiq/eval/dataset_handler/dataset_handler.py +87 -2
  71. aiq/eval/evaluate.py +208 -27
  72. aiq/eval/evaluator/base_evaluator.py +73 -0
  73. aiq/eval/evaluator/evaluator_model.py +1 -0
  74. aiq/eval/intermediate_step_adapter.py +11 -5
  75. aiq/eval/rag_evaluator/evaluate.py +55 -15
  76. aiq/eval/rag_evaluator/register.py +6 -1
  77. aiq/eval/remote_workflow.py +7 -2
  78. aiq/eval/runners/__init__.py +14 -0
  79. aiq/eval/runners/config.py +39 -0
  80. aiq/eval/runners/multi_eval_runner.py +54 -0
  81. aiq/eval/trajectory_evaluator/evaluate.py +22 -65
  82. aiq/eval/tunable_rag_evaluator/evaluate.py +150 -168
  83. aiq/eval/tunable_rag_evaluator/register.py +2 -0
  84. aiq/eval/usage_stats.py +41 -0
  85. aiq/eval/utils/output_uploader.py +10 -1
  86. aiq/eval/utils/weave_eval.py +184 -0
  87. aiq/experimental/__init__.py +0 -0
  88. aiq/experimental/decorators/__init__.py +0 -0
  89. aiq/experimental/decorators/experimental_warning_decorator.py +130 -0
  90. aiq/experimental/inference_time_scaling/__init__.py +0 -0
  91. aiq/experimental/inference_time_scaling/editing/__init__.py +0 -0
  92. aiq/experimental/inference_time_scaling/editing/iterative_plan_refinement_editor.py +147 -0
  93. aiq/experimental/inference_time_scaling/editing/llm_as_a_judge_editor.py +204 -0
  94. aiq/experimental/inference_time_scaling/editing/motivation_aware_summarization.py +107 -0
  95. aiq/experimental/inference_time_scaling/functions/__init__.py +0 -0
  96. aiq/experimental/inference_time_scaling/functions/execute_score_select_function.py +105 -0
  97. aiq/experimental/inference_time_scaling/functions/its_tool_orchestration_function.py +205 -0
  98. aiq/experimental/inference_time_scaling/functions/its_tool_wrapper_function.py +146 -0
  99. aiq/experimental/inference_time_scaling/functions/plan_select_execute_function.py +224 -0
  100. aiq/experimental/inference_time_scaling/models/__init__.py +0 -0
  101. aiq/experimental/inference_time_scaling/models/editor_config.py +132 -0
  102. aiq/experimental/inference_time_scaling/models/its_item.py +48 -0
  103. aiq/experimental/inference_time_scaling/models/scoring_config.py +112 -0
  104. aiq/experimental/inference_time_scaling/models/search_config.py +120 -0
  105. aiq/experimental/inference_time_scaling/models/selection_config.py +154 -0
  106. aiq/experimental/inference_time_scaling/models/stage_enums.py +43 -0
  107. aiq/experimental/inference_time_scaling/models/strategy_base.py +66 -0
  108. aiq/experimental/inference_time_scaling/models/tool_use_config.py +41 -0
  109. aiq/experimental/inference_time_scaling/register.py +36 -0
  110. aiq/experimental/inference_time_scaling/scoring/__init__.py +0 -0
  111. aiq/experimental/inference_time_scaling/scoring/llm_based_agent_scorer.py +168 -0
  112. aiq/experimental/inference_time_scaling/scoring/llm_based_plan_scorer.py +168 -0
  113. aiq/experimental/inference_time_scaling/scoring/motivation_aware_scorer.py +111 -0
  114. aiq/experimental/inference_time_scaling/search/__init__.py +0 -0
  115. aiq/experimental/inference_time_scaling/search/multi_llm_planner.py +128 -0
  116. aiq/experimental/inference_time_scaling/search/multi_query_retrieval_search.py +122 -0
  117. aiq/experimental/inference_time_scaling/search/single_shot_multi_plan_planner.py +128 -0
  118. aiq/experimental/inference_time_scaling/selection/__init__.py +0 -0
  119. aiq/experimental/inference_time_scaling/selection/best_of_n_selector.py +63 -0
  120. aiq/experimental/inference_time_scaling/selection/llm_based_agent_output_selector.py +131 -0
  121. aiq/experimental/inference_time_scaling/selection/llm_based_output_merging_selector.py +159 -0
  122. aiq/experimental/inference_time_scaling/selection/llm_based_plan_selector.py +128 -0
  123. aiq/experimental/inference_time_scaling/selection/threshold_selector.py +58 -0
  124. aiq/front_ends/console/authentication_flow_handler.py +233 -0
  125. aiq/front_ends/console/console_front_end_plugin.py +11 -2
  126. aiq/front_ends/fastapi/auth_flow_handlers/__init__.py +0 -0
  127. aiq/front_ends/fastapi/auth_flow_handlers/http_flow_handler.py +27 -0
  128. aiq/front_ends/fastapi/auth_flow_handlers/websocket_flow_handler.py +107 -0
  129. aiq/front_ends/fastapi/fastapi_front_end_config.py +93 -9
  130. aiq/front_ends/fastapi/fastapi_front_end_controller.py +68 -0
  131. aiq/front_ends/fastapi/fastapi_front_end_plugin.py +14 -1
  132. aiq/front_ends/fastapi/fastapi_front_end_plugin_worker.py +537 -52
  133. aiq/front_ends/fastapi/html_snippets/__init__.py +14 -0
  134. aiq/front_ends/fastapi/html_snippets/auth_code_grant_success.py +35 -0
  135. aiq/front_ends/fastapi/job_store.py +47 -25
  136. aiq/front_ends/fastapi/main.py +2 -0
  137. aiq/front_ends/fastapi/message_handler.py +108 -89
  138. aiq/front_ends/fastapi/step_adaptor.py +2 -1
  139. aiq/llm/aws_bedrock_llm.py +57 -0
  140. aiq/llm/nim_llm.py +2 -1
  141. aiq/llm/openai_llm.py +3 -2
  142. aiq/llm/register.py +1 -0
  143. aiq/meta/pypi.md +12 -12
  144. aiq/object_store/__init__.py +20 -0
  145. aiq/object_store/in_memory_object_store.py +74 -0
  146. aiq/object_store/interfaces.py +84 -0
  147. aiq/object_store/models.py +36 -0
  148. aiq/object_store/register.py +20 -0
  149. aiq/observability/__init__.py +14 -0
  150. aiq/observability/exporter/__init__.py +14 -0
  151. aiq/observability/exporter/base_exporter.py +449 -0
  152. aiq/observability/exporter/exporter.py +78 -0
  153. aiq/observability/exporter/file_exporter.py +33 -0
  154. aiq/observability/exporter/processing_exporter.py +269 -0
  155. aiq/observability/exporter/raw_exporter.py +52 -0
  156. aiq/observability/exporter/span_exporter.py +264 -0
  157. aiq/observability/exporter_manager.py +335 -0
  158. aiq/observability/mixin/__init__.py +14 -0
  159. aiq/observability/mixin/batch_config_mixin.py +26 -0
  160. aiq/observability/mixin/collector_config_mixin.py +23 -0
  161. aiq/observability/mixin/file_mixin.py +288 -0
  162. aiq/observability/mixin/file_mode.py +23 -0
  163. aiq/observability/mixin/resource_conflict_mixin.py +134 -0
  164. aiq/observability/mixin/serialize_mixin.py +61 -0
  165. aiq/observability/mixin/type_introspection_mixin.py +183 -0
  166. aiq/observability/processor/__init__.py +14 -0
  167. aiq/observability/processor/batching_processor.py +316 -0
  168. aiq/observability/processor/intermediate_step_serializer.py +28 -0
  169. aiq/observability/processor/processor.py +68 -0
  170. aiq/observability/register.py +36 -39
  171. aiq/observability/utils/__init__.py +14 -0
  172. aiq/observability/utils/dict_utils.py +236 -0
  173. aiq/observability/utils/time_utils.py +31 -0
  174. aiq/profiler/calc/__init__.py +14 -0
  175. aiq/profiler/calc/calc_runner.py +623 -0
  176. aiq/profiler/calc/calculations.py +288 -0
  177. aiq/profiler/calc/data_models.py +176 -0
  178. aiq/profiler/calc/plot.py +345 -0
  179. aiq/profiler/callbacks/langchain_callback_handler.py +22 -10
  180. aiq/profiler/data_models.py +24 -0
  181. aiq/profiler/inference_metrics_model.py +3 -0
  182. aiq/profiler/inference_optimization/bottleneck_analysis/nested_stack_analysis.py +8 -0
  183. aiq/profiler/inference_optimization/data_models.py +2 -2
  184. aiq/profiler/inference_optimization/llm_metrics.py +2 -2
  185. aiq/profiler/profile_runner.py +61 -21
  186. aiq/runtime/loader.py +9 -3
  187. aiq/runtime/runner.py +23 -9
  188. aiq/runtime/session.py +25 -7
  189. aiq/runtime/user_metadata.py +2 -3
  190. aiq/tool/chat_completion.py +74 -0
  191. aiq/tool/code_execution/README.md +152 -0
  192. aiq/tool/code_execution/code_sandbox.py +151 -72
  193. aiq/tool/code_execution/local_sandbox/.gitignore +1 -0
  194. aiq/tool/code_execution/local_sandbox/local_sandbox_server.py +139 -24
  195. aiq/tool/code_execution/local_sandbox/sandbox.requirements.txt +3 -1
  196. aiq/tool/code_execution/local_sandbox/start_local_sandbox.sh +27 -2
  197. aiq/tool/code_execution/register.py +7 -3
  198. aiq/tool/code_execution/test_code_execution_sandbox.py +414 -0
  199. aiq/tool/mcp/exceptions.py +142 -0
  200. aiq/tool/mcp/mcp_client.py +41 -6
  201. aiq/tool/mcp/mcp_tool.py +3 -2
  202. aiq/tool/register.py +1 -0
  203. aiq/tool/server_tools.py +6 -3
  204. aiq/utils/exception_handlers/automatic_retries.py +289 -0
  205. aiq/utils/exception_handlers/mcp.py +211 -0
  206. aiq/utils/io/model_processing.py +28 -0
  207. aiq/utils/log_utils.py +37 -0
  208. aiq/utils/string_utils.py +38 -0
  209. aiq/utils/type_converter.py +18 -2
  210. aiq/utils/type_utils.py +87 -0
  211. {aiqtoolkit-1.2.0.dev0.dist-info → aiqtoolkit-1.2.0rc1.dist-info}/METADATA +53 -21
  212. aiqtoolkit-1.2.0rc1.dist-info/RECORD +436 -0
  213. {aiqtoolkit-1.2.0.dev0.dist-info → aiqtoolkit-1.2.0rc1.dist-info}/WHEEL +1 -1
  214. {aiqtoolkit-1.2.0.dev0.dist-info → aiqtoolkit-1.2.0rc1.dist-info}/entry_points.txt +3 -0
  215. aiq/front_ends/fastapi/websocket.py +0 -148
  216. aiq/observability/async_otel_listener.py +0 -429
  217. aiqtoolkit-1.2.0.dev0.dist-info/RECORD +0 -316
  218. {aiqtoolkit-1.2.0.dev0.dist-info → aiqtoolkit-1.2.0rc1.dist-info}/licenses/LICENSE-3rd-party.txt +0 -0
  219. {aiqtoolkit-1.2.0.dev0.dist-info → aiqtoolkit-1.2.0rc1.dist-info}/licenses/LICENSE.md +0 -0
  220. {aiqtoolkit-1.2.0.dev0.dist-info → aiqtoolkit-1.2.0rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,269 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ import asyncio
17
+ import logging
18
+ from abc import abstractmethod
19
+ from collections.abc import Coroutine
20
+ from typing import Generic
21
+ from typing import TypeVar
22
+
23
+ from aiq.builder.context import AIQContextState
24
+ from aiq.data_models.intermediate_step import IntermediateStep
25
+ from aiq.observability.exporter.base_exporter import BaseExporter
26
+ from aiq.observability.mixin.type_introspection_mixin import TypeIntrospectionMixin
27
+ from aiq.observability.processor.processor import Processor
28
+ from aiq.utils.type_utils import DecomposedType
29
+ from aiq.utils.type_utils import override
30
+
31
+ PipelineInputT = TypeVar("PipelineInputT")
32
+ PipelineOutputT = TypeVar("PipelineOutputT")
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter, TypeIntrospectionMixin):
38
+ """A base class for telemetry exporters with processing pipeline support.
39
+
40
+ This class extends BaseExporter to add processor pipeline functionality.
41
+ It manages a chain of processors that can transform items before export.
42
+
43
+ The generic types work as follows:
44
+ - PipelineInputT: The type of items that enter the processing pipeline (e.g., Span)
45
+ - PipelineOutputT: The type of items after processing through the pipeline (e.g., converted format)
46
+
47
+ Key Features:
48
+ - Processor pipeline management (add, remove, clear)
49
+ - Type compatibility validation between processors
50
+ - Pipeline processing with error handling
51
+ - Automatic type validation before export
52
+ """
53
+
54
+ def __init__(self, context_state: AIQContextState | None = None):
55
+ """Initialize the processing exporter.
56
+
57
+ Args:
58
+ context_state: The context state to use for the exporter.
59
+ """
60
+ super().__init__(context_state)
61
+ self._processors: list[Processor] = [] # List of processors that implement process(item) -> item
62
+
63
+ def add_processor(self, processor: Processor) -> None:
64
+ """Add a processor to the processing pipeline.
65
+
66
+ Processors are executed in the order they are added.
67
+ Processors can transform between any types (T -> U).
68
+
69
+ Args:
70
+ processor: The processor to add to the pipeline
71
+ """
72
+
73
+ # Check if the processor is compatible with the last processor in the pipeline
74
+ if len(self._processors) > 0:
75
+ try:
76
+ if not issubclass(processor.input_class, self._processors[-1].output_class):
77
+ raise ValueError(f"Processor {processor.__class__.__name__} input type {processor.input_type} "
78
+ f"is not compatible with the {self._processors[-1].__class__.__name__} "
79
+ f"output type {self._processors[-1].output_type}")
80
+ except TypeError:
81
+ # Handle cases where input_class or output_class are generic types that can't be used with issubclass
82
+ # Fall back to type comparison for generic types
83
+ logger.warning(
84
+ "Cannot use issubclass() for type compatibility check between "
85
+ "%s (%s) and %s (%s). Skipping compatibility check.",
86
+ processor.__class__.__name__,
87
+ processor.input_type,
88
+ self._processors[-1].__class__.__name__,
89
+ self._processors[-1].output_type)
90
+ self._processors.append(processor)
91
+
92
+ def remove_processor(self, processor: Processor) -> None:
93
+ """Remove a processor from the processing pipeline.
94
+
95
+ Args:
96
+ processor: The processor to remove from the pipeline
97
+ """
98
+ if processor in self._processors:
99
+ self._processors.remove(processor)
100
+
101
+ def clear_processors(self) -> None:
102
+ """Clear all processors from the pipeline."""
103
+ self._processors.clear()
104
+
105
+ async def _pre_start(self) -> None:
106
+ if len(self._processors) > 0:
107
+ first_processor = self._processors[0]
108
+ last_processor = self._processors[-1]
109
+
110
+ # validate that the first processor's input type is compatible with the exporter's input type
111
+ try:
112
+ if not issubclass(first_processor.input_class, self.input_class):
113
+ raise ValueError(f"Processor {first_processor.__class__.__name__} input type "
114
+ f"{first_processor.input_type} is not compatible with the "
115
+ f"{self.input_type} input type")
116
+ except TypeError as e:
117
+ # Handle cases where classes are generic types that can't be used with issubclass
118
+ logger.warning(
119
+ "Cannot validate type compatibility between %s (%s) "
120
+ "and exporter (%s): %s. Skipping validation.",
121
+ first_processor.__class__.__name__,
122
+ first_processor.input_type,
123
+ self.input_type,
124
+ e)
125
+
126
+ # Validate that the last processor's output type is compatible with the exporter's output type
127
+ try:
128
+ if not DecomposedType.is_type_compatible(last_processor.output_type, self.output_type):
129
+ raise ValueError(f"Processor {last_processor.__class__.__name__} output type "
130
+ f"{last_processor.output_type} is not compatible with the "
131
+ f"{self.output_type} output type")
132
+ except TypeError as e:
133
+ # Handle cases where classes are generic types that can't be used with issubclass
134
+ logger.warning(
135
+ "Cannot validate type compatibility between %s (%s) "
136
+ "and exporter (%s): %s. Skipping validation.",
137
+ last_processor.__class__.__name__,
138
+ last_processor.output_type,
139
+ self.output_type,
140
+ e)
141
+
142
+ async def _process_pipeline(self, item: PipelineInputT) -> PipelineOutputT:
143
+ """Process item through all registered processors.
144
+
145
+ Args:
146
+ item: The item to process (starts as PipelineInputT, can transform to PipelineOutputT)
147
+
148
+ Returns:
149
+ The processed item after running through all processors
150
+ """
151
+ processed_item = item
152
+ for processor in self._processors:
153
+ try:
154
+ processed_item = await processor.process(processed_item)
155
+ except Exception as e:
156
+ logger.error("Error in processor %s: %s", processor.__class__.__name__, e, exc_info=True)
157
+ # Continue with unprocessed item rather than failing the export
158
+
159
+ return processed_item # type: ignore
160
+
161
+ async def _export_with_processing(self, item: PipelineInputT) -> None:
162
+ """Export an item after processing it through the pipeline.
163
+
164
+ Args:
165
+ item: The item to export
166
+ """
167
+ try:
168
+ # Then, run through the processor pipeline
169
+ final_item: PipelineOutputT = await self._process_pipeline(item)
170
+
171
+ # Handle different output types from batch processors
172
+ if isinstance(final_item, list):
173
+ # Empty lists from batch processors should be skipped, not exported
174
+ if len(final_item) == 0:
175
+ logger.debug("Skipping export of empty batch from processor pipeline")
176
+ return
177
+
178
+ # Non-empty lists should be exported (batch processors)
179
+ await self.export_processed(final_item)
180
+ elif isinstance(final_item, self.output_class):
181
+ # Single items should be exported normally
182
+ await self.export_processed(final_item)
183
+ else:
184
+ raise ValueError(f"Processed item {final_item} is not a valid output type. "
185
+ f"Expected {self.output_class} or list[{self.output_class}]")
186
+
187
+ except Exception as e:
188
+ logger.error("Failed to export item '%s': %s", item, e, exc_info=True)
189
+ raise
190
+
191
+ @override
192
+ def export(self, event: IntermediateStep) -> None:
193
+ """Export an IntermediateStep event through the processing pipeline.
194
+
195
+ This method converts the IntermediateStep to the expected PipelineInputT type,
196
+ processes it through the pipeline, and exports the result.
197
+
198
+ Args:
199
+ event (IntermediateStep): The event to be exported.
200
+ """
201
+ # Convert IntermediateStep to PipelineInputT and create export task
202
+ if isinstance(event, self.input_class):
203
+ input_item: PipelineInputT = event # type: ignore
204
+ coro = self._export_with_processing(input_item)
205
+ self._create_export_task(coro)
206
+ else:
207
+ logger.warning("Event %s is not compatible with input type %s", event, self.input_type)
208
+
209
+ @abstractmethod
210
+ async def export_processed(self, item: PipelineOutputT | list[PipelineOutputT]) -> None:
211
+ """Export the processed item.
212
+
213
+ This method must be implemented by concrete exporters to handle
214
+ the actual export logic after the item has been processed through the pipeline.
215
+
216
+ Args:
217
+ item: The processed item to export (PipelineOutputT type)
218
+ """
219
+ pass
220
+
221
+ def _create_export_task(self, coro: Coroutine):
222
+ """Create task with minimal overhead but proper tracking."""
223
+ if not self._running:
224
+ logger.warning("%s: Attempted to create export task while not running", self.name)
225
+ return
226
+
227
+ try:
228
+ task = asyncio.create_task(coro)
229
+ self._tasks.add(task)
230
+ task.add_done_callback(self._tasks.discard)
231
+
232
+ except Exception as e:
233
+ logger.error("%s: Failed to create task: %s", self.name, e, exc_info=True)
234
+ raise
235
+
236
+ @override
237
+ async def _cleanup(self):
238
+ """Enhanced cleanup that shuts down all shutdown-aware processors."""
239
+ # Shutdown all processors that support it
240
+ if hasattr(self, '_processors'):
241
+ shutdown_tasks = []
242
+ for processor in getattr(self, '_processors', []):
243
+ if hasattr(processor, 'shutdown'):
244
+ logger.debug("Shutting down processor: %s", processor.__class__.__name__)
245
+ shutdown_tasks.append(processor.shutdown())
246
+
247
+ if shutdown_tasks:
248
+ try:
249
+ await asyncio.gather(*shutdown_tasks, return_exceptions=True)
250
+ logger.info("Successfully shut down %d processors", len(shutdown_tasks))
251
+ except Exception as e:
252
+ logger.error("Error shutting down processors: %s", e, exc_info=True)
253
+
254
+ # Process final batches from batch processors
255
+ for processor in getattr(self, '_processors', []):
256
+ if hasattr(processor, 'has_final_batch') and hasattr(processor, 'get_final_batch'):
257
+ if processor.has_final_batch():
258
+ final_batch = processor.get_final_batch()
259
+ if final_batch:
260
+ logger.info("Processing final batch of %d items from %s during cleanup",
261
+ len(final_batch),
262
+ processor.__class__.__name__)
263
+ try:
264
+ await self.export_processed(final_batch)
265
+ except Exception as e:
266
+ logger.error("Error processing final batch during cleanup: %s", e, exc_info=True)
267
+
268
+ # Call parent cleanup
269
+ await super()._cleanup()
@@ -0,0 +1,52 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ import logging
17
+ from abc import abstractmethod
18
+ from typing import TypeVar
19
+
20
+ from aiq.data_models.intermediate_step import IntermediateStep
21
+ from aiq.observability.exporter.processing_exporter import ProcessingExporter
22
+ from aiq.utils.type_utils import override
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ InputT = TypeVar("InputT")
27
+ OutputT = TypeVar("OutputT")
28
+
29
+
30
+ class RawExporter(ProcessingExporter[InputT, OutputT]):
31
+ """A base class for exporting raw intermediate steps.
32
+
33
+ This class provides a base implementation for telemetry exporters that
34
+ work directly with IntermediateStep objects. It can optionally process
35
+ them through a pipeline before export.
36
+
37
+ The flow is: IntermediateStep -> [Processing Pipeline] -> OutputT -> Export
38
+
39
+ Args:
40
+ context_state (AIQContextState, optional): The context state to use for the exporter. Defaults to None.
41
+ """
42
+
43
+ @abstractmethod
44
+ async def export_processed(self, item: OutputT):
45
+ pass
46
+
47
+ @override
48
+ def export(self, event: IntermediateStep):
49
+ if not isinstance(event, IntermediateStep):
50
+ return
51
+
52
+ self._create_export_task(self._export_with_processing(event)) # type: ignore
@@ -0,0 +1,264 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ import logging
17
+ import re
18
+ from abc import abstractmethod
19
+ from typing import TypeVar
20
+
21
+ from aiq.data_models.intermediate_step import IntermediateStep
22
+ from aiq.data_models.intermediate_step import IntermediateStepState
23
+ from aiq.data_models.intermediate_step import TraceMetadata
24
+ from aiq.data_models.span import MimeTypes
25
+ from aiq.data_models.span import Span
26
+ from aiq.data_models.span import SpanAttributes
27
+ from aiq.data_models.span import SpanContext
28
+ from aiq.data_models.span import event_type_to_span_kind
29
+ from aiq.observability.exporter.base_exporter import IsolatedAttribute
30
+ from aiq.observability.exporter.processing_exporter import ProcessingExporter
31
+ from aiq.observability.mixin.serialize_mixin import SerializeMixin
32
+ from aiq.observability.utils.dict_utils import merge_dicts
33
+ from aiq.observability.utils.time_utils import ns_timestamp
34
+ from aiq.utils.type_utils import override
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ InputSpanT = TypeVar("InputSpanT")
39
+ OutputSpanT = TypeVar("OutputSpanT")
40
+
41
+
42
+ class SpanExporter(ProcessingExporter[InputSpanT, OutputSpanT], SerializeMixin):
43
+ """Abstract base class for span exporters with processing pipeline support.
44
+
45
+ This class specializes ProcessingExporter for span-based telemetry export. It converts
46
+ IntermediateStep events into Span objects and supports processing pipelines for
47
+ span transformation before export.
48
+
49
+ The generic types work as follows:
50
+ - InputSpanT: The type of spans that enter the processing pipeline (typically Span)
51
+ - OutputSpanT: The type of spans after processing through the pipeline (e.g., OtelSpan)
52
+
53
+ Key Features:
54
+ - Automatic span creation from IntermediateStep events
55
+ - Span lifecycle management (start/end event tracking)
56
+ - Processing pipeline support via ProcessingExporter
57
+ - Metadata and attribute handling
58
+ - Usage information tracking
59
+ - Automatic isolation of mutable state for concurrent execution using descriptors
60
+
61
+ Inheritance Hierarchy:
62
+ - BaseExporter: Core event subscription and lifecycle management + DescriptorIsolationMixin
63
+ - ProcessingExporter: Adds processor pipeline functionality
64
+ - SpanExporter: Specializes for span creation and export
65
+
66
+ Event Processing Flow:
67
+ 1. IntermediateStep (START) → Create Span → Add to tracking
68
+ 2. IntermediateStep (END) → Complete Span → Process through pipeline → Export
69
+
70
+ Args:
71
+ context_state (AIQContextState, optional): The context state to use for the exporter. Defaults to None.
72
+ """
73
+
74
+ # Use descriptors for automatic isolation of span-specific state
75
+ _outstanding_spans: IsolatedAttribute[dict] = IsolatedAttribute(dict)
76
+ _span_stack: IsolatedAttribute[dict] = IsolatedAttribute(dict)
77
+ _metadata_stack: IsolatedAttribute[dict] = IsolatedAttribute(dict)
78
+
79
+ @abstractmethod
80
+ async def export_processed(self, item: OutputSpanT) -> None:
81
+ """Export the processed span.
82
+
83
+ Args:
84
+ item (OutputSpanT): The processed span to export.
85
+ """
86
+ pass
87
+
88
+ @override
89
+ def export(self, event: IntermediateStep) -> None:
90
+ """The main logic that reacts to each IntermediateStep.
91
+
92
+ Args:
93
+ event (IntermediateStep): The event to process.
94
+ """
95
+ if not isinstance(event, IntermediateStep):
96
+ return
97
+
98
+ if (event.event_state == IntermediateStepState.START):
99
+ self._process_start_event(event)
100
+ elif (event.event_state == IntermediateStepState.END):
101
+ self._process_end_event(event)
102
+
103
+ def _process_start_event(self, event: IntermediateStep):
104
+ """Process the start event of an intermediate step.
105
+
106
+ Args:
107
+ event (IntermediateStep): The event to process.
108
+ """
109
+
110
+ parent_span = None
111
+ span_ctx = None
112
+
113
+ # Look up the parent span to establish hierarchy
114
+ # event.parent_id is the UUID of the last START step with a different UUID from current step
115
+ # This maintains proper parent-child relationships in the span tree
116
+ # Skip lookup if parent_id is "root" (indicates this is a top-level span)
117
+ if len(self._span_stack) > 0 and event.parent_id and event.parent_id != "root":
118
+
119
+ parent_span = self._span_stack.get(event.parent_id, None)
120
+ if parent_span is None:
121
+ logger.warning("No parent span found for step %s", event.UUID)
122
+ return
123
+
124
+ parent_span = parent_span.model_copy() if isinstance(parent_span, Span) else None
125
+ if parent_span and parent_span.context:
126
+ span_ctx = SpanContext(trace_id=parent_span.context.trace_id)
127
+
128
+ # Extract start/end times from the step
129
+ # By convention, `span_event_timestamp` is the time we started, `event_timestamp` is the time we ended.
130
+ # If span_event_timestamp is missing, we default to event_timestamp (meaning zero-length).
131
+ s_ts = event.payload.span_event_timestamp or event.payload.event_timestamp
132
+ start_ns = ns_timestamp(s_ts)
133
+
134
+ # Optional: embed the LLM/tool name if present
135
+ if event.payload.name:
136
+ sub_span_name = f"{event.payload.name}"
137
+ else:
138
+ sub_span_name = f"{event.payload.event_type}"
139
+
140
+ sub_span = Span(
141
+ name=sub_span_name,
142
+ parent=parent_span,
143
+ context=span_ctx,
144
+ attributes={
145
+ "aiq.event_type": event.payload.event_type.value,
146
+ "aiq.function.id": event.function_ancestry.function_id if event.function_ancestry else "unknown",
147
+ "aiq.function.name": event.function_ancestry.function_name if event.function_ancestry else "unknown",
148
+ "aiq.subspan.name": event.payload.name or "",
149
+ "aiq.event_timestamp": event.event_timestamp,
150
+ "aiq.framework": event.payload.framework.value if event.payload.framework else "unknown",
151
+ },
152
+ start_time=start_ns)
153
+
154
+ span_kind = event_type_to_span_kind(event.event_type)
155
+ sub_span.set_attribute("aiq.span.kind", span_kind.value)
156
+
157
+ if event.payload.data and event.payload.data.input:
158
+ match = re.search(r"Human:\s*Question:\s*(.*)", str(event.payload.data.input))
159
+ if match:
160
+ human_question = match.group(1).strip()
161
+ sub_span.set_attribute(SpanAttributes.INPUT_VALUE.value, human_question)
162
+ else:
163
+ serialized_input, is_json = self._serialize_payload(event.payload.data.input)
164
+ sub_span.set_attribute(SpanAttributes.INPUT_VALUE.value, serialized_input)
165
+ sub_span.set_attribute(SpanAttributes.INPUT_MIME_TYPE.value,
166
+ MimeTypes.JSON.value if is_json else MimeTypes.TEXT.value)
167
+
168
+ # Add metadata to the metadata stack
169
+ start_metadata = event.payload.metadata or {}
170
+
171
+ if isinstance(start_metadata, dict):
172
+ self._metadata_stack[event.UUID] = start_metadata # type: ignore
173
+ elif isinstance(start_metadata, TraceMetadata):
174
+ self._metadata_stack[event.UUID] = start_metadata.model_dump() # type: ignore
175
+ else:
176
+ logger.warning("Invalid metadata type for step %s", event.UUID)
177
+ return
178
+
179
+ self._span_stack[event.UUID] = sub_span # type: ignore
180
+ self._outstanding_spans[event.UUID] = sub_span # type: ignore
181
+
182
+ logger.debug(
183
+ "Added span to tracking (outstanding: %d, stack: %d, event_id: %s)",
184
+ len(self._outstanding_spans), # type: ignore
185
+ len(self._span_stack), # type: ignore
186
+ event.UUID)
187
+
188
+ def _process_end_event(self, event: IntermediateStep):
189
+ """Process the end event of an intermediate step.
190
+
191
+ Args:
192
+ event (IntermediateStep): The event to process.
193
+ """
194
+
195
+ # Find the subspan that was created in the start event
196
+ sub_span: Span | None = self._outstanding_spans.pop(event.UUID, None) # type: ignore
197
+
198
+ if sub_span is None:
199
+ logger.warning("No subspan found for step %s", event.UUID)
200
+ return
201
+
202
+ self._span_stack.pop(event.UUID, None) # type: ignore
203
+
204
+ # Optionally add more attributes from usage_info or data
205
+ usage_info = event.payload.usage_info
206
+ if usage_info:
207
+ sub_span.set_attribute(SpanAttributes.AIQ_USAGE_NUM_LLM_CALLS.value,
208
+ usage_info.num_llm_calls if usage_info.num_llm_calls else 0)
209
+ sub_span.set_attribute(SpanAttributes.AIQ_USAGE_SECONDS_BETWEEN_CALLS.value,
210
+ usage_info.seconds_between_calls if usage_info.seconds_between_calls else 0)
211
+ sub_span.set_attribute(SpanAttributes.LLM_TOKEN_COUNT_PROMPT.value,
212
+ usage_info.token_usage.prompt_tokens if usage_info.token_usage else 0)
213
+ sub_span.set_attribute(SpanAttributes.LLM_TOKEN_COUNT_COMPLETION.value,
214
+ usage_info.token_usage.completion_tokens if usage_info.token_usage else 0)
215
+ sub_span.set_attribute(SpanAttributes.LLM_TOKEN_COUNT_TOTAL.value,
216
+ usage_info.token_usage.total_tokens if usage_info.token_usage else 0)
217
+
218
+ if event.payload.data and event.payload.data.output is not None:
219
+ serialized_output, is_json = self._serialize_payload(event.payload.data.output)
220
+ sub_span.set_attribute(SpanAttributes.OUTPUT_VALUE.value, serialized_output)
221
+ sub_span.set_attribute(SpanAttributes.OUTPUT_MIME_TYPE.value,
222
+ MimeTypes.JSON.value if is_json else MimeTypes.TEXT.value)
223
+
224
+ # Merge metadata from start event with end event metadata
225
+ start_metadata = self._metadata_stack.pop(event.UUID) # type: ignore
226
+
227
+ if start_metadata is None:
228
+ logger.warning("No metadata found for step %s", event.UUID)
229
+ return
230
+
231
+ end_metadata = event.payload.metadata or {}
232
+
233
+ if not isinstance(end_metadata, (dict, TraceMetadata)):
234
+ logger.warning("Invalid metadata type for step %s", event.UUID)
235
+ return
236
+
237
+ if isinstance(end_metadata, TraceMetadata):
238
+ end_metadata = end_metadata.model_dump()
239
+
240
+ merged_metadata = merge_dicts(start_metadata, end_metadata)
241
+ serialized_metadata, is_json = self._serialize_payload(merged_metadata)
242
+ sub_span.set_attribute("aiq.metadata", serialized_metadata)
243
+ sub_span.set_attribute("aiq.metadata.mime_type", MimeTypes.JSON.value if is_json else MimeTypes.TEXT.value)
244
+
245
+ end_ns = ns_timestamp(event.payload.event_timestamp)
246
+
247
+ # End the subspan
248
+ sub_span.end(end_time=end_ns)
249
+
250
+ # Export the span with processing pipeline
251
+ self._create_export_task(self._export_with_processing(sub_span)) # type: ignore
252
+
253
+ @override
254
+ async def _cleanup(self):
255
+ """Clean up any remaining spans."""
256
+ if self._outstanding_spans: # type: ignore
257
+ logger.warning("Not all spans were closed. Remaining: %s", self._outstanding_spans) # type: ignore
258
+
259
+ for span_info in self._outstanding_spans.values(): # type: ignore
260
+ span_info.end()
261
+
262
+ self._outstanding_spans.clear() # type: ignore
263
+ self._span_stack.clear() # type: ignore
264
+ self._metadata_stack.clear() # type: ignore