nvidia-nat 1.3.0a20250904__py3-none-any.whl → 1.3.0a20250909__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.
- nat/builder/component_utils.py +1 -1
- nat/cli/commands/info/list_mcp.py +29 -7
- nat/cli/commands/workflow/templates/pyproject.toml.j2 +1 -1
- nat/data_models/common.py +1 -1
- nat/data_models/thinking_mixin.py +2 -3
- nat/eval/utils/weave_eval.py +6 -4
- nat/front_ends/fastapi/fastapi_front_end_config.py +18 -2
- nat/observability/exporter/processing_exporter.py +294 -39
- nat/observability/mixin/redaction_config_mixin.py +41 -0
- nat/observability/mixin/tagging_config_mixin.py +50 -0
- nat/observability/processor/header_redaction_processor.py +123 -0
- nat/observability/processor/redaction_processor.py +77 -0
- nat/observability/processor/span_tagging_processor.py +61 -0
- nat/tool/register.py +0 -2
- {nvidia_nat-1.3.0a20250904.dist-info → nvidia_nat-1.3.0a20250909.dist-info}/METADATA +7 -2
- {nvidia_nat-1.3.0a20250904.dist-info → nvidia_nat-1.3.0a20250909.dist-info}/RECORD +21 -22
- nat/tool/mcp/__init__.py +0 -14
- nat/tool/mcp/exceptions.py +0 -142
- nat/tool/mcp/mcp_client_base.py +0 -406
- nat/tool/mcp/mcp_client_impl.py +0 -229
- nat/tool/mcp/mcp_tool.py +0 -133
- nat/utils/exception_handlers/mcp.py +0 -211
- {nvidia_nat-1.3.0a20250904.dist-info → nvidia_nat-1.3.0a20250909.dist-info}/WHEEL +0 -0
- {nvidia_nat-1.3.0a20250904.dist-info → nvidia_nat-1.3.0a20250909.dist-info}/entry_points.txt +0 -0
- {nvidia_nat-1.3.0a20250904.dist-info → nvidia_nat-1.3.0a20250909.dist-info}/licenses/LICENSE-3rd-party.txt +0 -0
- {nvidia_nat-1.3.0a20250904.dist-info → nvidia_nat-1.3.0a20250909.dist-info}/licenses/LICENSE.md +0 -0
- {nvidia_nat-1.3.0a20250904.dist-info → nvidia_nat-1.3.0a20250909.dist-info}/top_level.txt +0 -0
nat/builder/component_utils.py
CHANGED
|
@@ -174,7 +174,7 @@ def update_dependency_graph(config: "Config", instance_config: TypedBaseModel,
|
|
|
174
174
|
nx.DiGraph: An dependency graph that has been updated with the provided runtime instance.
|
|
175
175
|
"""
|
|
176
176
|
|
|
177
|
-
for field_name, field_info in instance_config.model_fields.items():
|
|
177
|
+
for field_name, field_info in type(instance_config).model_fields.items():
|
|
178
178
|
|
|
179
179
|
for instance_id, value_node in recursive_componentref_discovery(
|
|
180
180
|
instance_config,
|
|
@@ -22,8 +22,16 @@ from typing import Any
|
|
|
22
22
|
import click
|
|
23
23
|
from pydantic import BaseModel
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
from nat.
|
|
25
|
+
try:
|
|
26
|
+
from nat.plugins.mcp.exception_handler import format_mcp_error
|
|
27
|
+
from nat.plugins.mcp.exceptions import MCPError
|
|
28
|
+
except ImportError:
|
|
29
|
+
# Fallback for when MCP client package is not installed
|
|
30
|
+
MCPError = Exception
|
|
31
|
+
|
|
32
|
+
def format_mcp_error(error, include_traceback=False):
|
|
33
|
+
click.echo(f"Error: {error}", err=True)
|
|
34
|
+
|
|
27
35
|
|
|
28
36
|
# Suppress verbose logs from mcp.client.sse and httpx
|
|
29
37
|
logging.getLogger("mcp.client.sse").setLevel(logging.WARNING)
|
|
@@ -145,9 +153,15 @@ async def list_tools_and_schemas(command, url, tool_name=None, transport='sse',
|
|
|
145
153
|
Raises:
|
|
146
154
|
MCPError: Caught internally and logged, returns empty list instead
|
|
147
155
|
"""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
try:
|
|
157
|
+
from nat.plugins.mcp.client_base import MCPSSEClient
|
|
158
|
+
from nat.plugins.mcp.client_base import MCPStdioClient
|
|
159
|
+
from nat.plugins.mcp.client_base import MCPStreamableHTTPClient
|
|
160
|
+
except ImportError:
|
|
161
|
+
click.echo(
|
|
162
|
+
"MCP client functionality requires nvidia-nat-mcp package. Install with: uv pip install nvidia-nat-mcp",
|
|
163
|
+
err=True)
|
|
164
|
+
return []
|
|
151
165
|
|
|
152
166
|
if args is None:
|
|
153
167
|
args = []
|
|
@@ -239,8 +253,16 @@ async def list_tools_direct(command, url, tool_name=None, transport='sse', args=
|
|
|
239
253
|
return tools
|
|
240
254
|
except Exception as e:
|
|
241
255
|
# Convert raw exceptions to structured MCPError for consistency
|
|
242
|
-
|
|
243
|
-
|
|
256
|
+
try:
|
|
257
|
+
from nat.plugins.mcp.exception_handler import convert_to_mcp_error
|
|
258
|
+
from nat.plugins.mcp.exception_handler import extract_primary_exception
|
|
259
|
+
except ImportError:
|
|
260
|
+
# Fallback when MCP client package is not installed
|
|
261
|
+
def convert_to_mcp_error(exception, url):
|
|
262
|
+
return Exception(f"Error connecting to {url}: {exception}")
|
|
263
|
+
|
|
264
|
+
def extract_primary_exception(exceptions):
|
|
265
|
+
return exceptions[0] if exceptions else Exception("Unknown error")
|
|
244
266
|
|
|
245
267
|
if isinstance(e, ExceptionGroup): # noqa: F821
|
|
246
268
|
primary_exception = extract_primary_exception(list(e.exceptions))
|
|
@@ -14,7 +14,7 @@ name = "{{ package_name }}"
|
|
|
14
14
|
dependencies = [
|
|
15
15
|
"{{ nat_dependency }}",
|
|
16
16
|
]
|
|
17
|
-
requires-python = ">=3.11,<3.
|
|
17
|
+
requires-python = ">=3.11,<3.14"
|
|
18
18
|
description = "Custom NeMo Agent Toolkit Workflow"
|
|
19
19
|
classifiers = ["Programming Language :: Python"]
|
|
20
20
|
|
nat/data_models/common.py
CHANGED
|
@@ -160,7 +160,7 @@ class TypedBaseModel(BaseModel):
|
|
|
160
160
|
|
|
161
161
|
@staticmethod
|
|
162
162
|
def discriminator(v: typing.Any) -> str | None:
|
|
163
|
-
# If
|
|
163
|
+
# If it's serialized, then we use the alias
|
|
164
164
|
if isinstance(v, dict):
|
|
165
165
|
return v.get("_type", v.get("type"))
|
|
166
166
|
|
|
@@ -20,9 +20,9 @@ from pydantic import Field
|
|
|
20
20
|
|
|
21
21
|
from nat.data_models.gated_field_mixin import GatedFieldMixin
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
# regex patterns
|
|
23
|
+
# Currently the control logic for thinking is only implemented for Nemotron models
|
|
25
24
|
_NEMOTRON_REGEX = re.compile(r"^nvidia/(llama|nvidia).*nemotron", re.IGNORECASE)
|
|
25
|
+
# The keys are the fields that are used to determine if the model supports thinking
|
|
26
26
|
_MODEL_KEYS = ("model_name", "model", "azure_deployment")
|
|
27
27
|
|
|
28
28
|
|
|
@@ -43,7 +43,6 @@ class ThinkingMixin(
|
|
|
43
43
|
thinking: bool | None = Field(
|
|
44
44
|
default=None,
|
|
45
45
|
description="Whether to enable thinking. Defaults to None when supported on the model.",
|
|
46
|
-
exclude=True,
|
|
47
46
|
)
|
|
48
47
|
|
|
49
48
|
@property
|
nat/eval/utils/weave_eval.py
CHANGED
|
@@ -44,11 +44,9 @@ class WeaveEvaluationIntegration:
|
|
|
44
44
|
self.eval_trace_context = eval_trace_context
|
|
45
45
|
|
|
46
46
|
try:
|
|
47
|
-
from weave
|
|
48
|
-
from weave.flow.eval_imperative import ScoreLogger
|
|
47
|
+
from weave import EvaluationLogger
|
|
49
48
|
from weave.trace.context import weave_client_context
|
|
50
49
|
self.evaluation_logger_cls = EvaluationLogger
|
|
51
|
-
self.score_logger_cls = ScoreLogger
|
|
52
50
|
self.weave_client_context = weave_client_context
|
|
53
51
|
self.available = True
|
|
54
52
|
except ImportError:
|
|
@@ -94,7 +92,10 @@ class WeaveEvaluationIntegration:
|
|
|
94
92
|
weave_dataset = self._get_weave_dataset(eval_input)
|
|
95
93
|
config_dict = config.model_dump(mode="json")
|
|
96
94
|
config_dict["name"] = workflow_alias
|
|
97
|
-
self.eval_logger = self.evaluation_logger_cls(model=config_dict,
|
|
95
|
+
self.eval_logger = self.evaluation_logger_cls(model=config_dict,
|
|
96
|
+
dataset=weave_dataset,
|
|
97
|
+
name=workflow_alias,
|
|
98
|
+
eval_attributes={})
|
|
98
99
|
self.pred_loggers = {}
|
|
99
100
|
|
|
100
101
|
# Capture the current evaluation call for context propagation
|
|
@@ -189,3 +190,4 @@ class WeaveEvaluationIntegration:
|
|
|
189
190
|
# Log the summary to finish the evaluation, disable auto-summarize
|
|
190
191
|
# as we will be adding profiler metrics to the summary
|
|
191
192
|
self.eval_logger.log_summary(summary, auto_summarize=False)
|
|
193
|
+
logger.info("Logged Evaluation Summary to Weave")
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
# limitations under the License.
|
|
15
15
|
|
|
16
16
|
import logging
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
17
19
|
import typing
|
|
18
20
|
from datetime import datetime
|
|
19
21
|
from pathlib import Path
|
|
@@ -31,6 +33,20 @@ logger = logging.getLogger(__name__)
|
|
|
31
33
|
YAML_EXTENSIONS = (".yaml", ".yml")
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
def _is_reserved(path: Path) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Check if a path is reserved in the current Python version and platform.
|
|
39
|
+
|
|
40
|
+
On Windows, this function checks if the path is reserved in the current Python version.
|
|
41
|
+
On other platforms, returns False
|
|
42
|
+
"""
|
|
43
|
+
if sys.platform != "win32":
|
|
44
|
+
return False
|
|
45
|
+
if sys.version_info >= (3, 13):
|
|
46
|
+
return os.path.isreserved(path)
|
|
47
|
+
return path.is_reserved()
|
|
48
|
+
|
|
49
|
+
|
|
34
50
|
class EvaluateRequest(BaseModel):
|
|
35
51
|
"""Request model for the evaluate endpoint."""
|
|
36
52
|
config_file: str = Field(description="Path to the configuration file for evaluation")
|
|
@@ -51,7 +67,7 @@ class EvaluateRequest(BaseModel):
|
|
|
51
67
|
f"Job ID '{job_id}' contains invalid characters. Only alphanumeric characters and underscores are"
|
|
52
68
|
" allowed.")
|
|
53
69
|
|
|
54
|
-
if job_id_path
|
|
70
|
+
if _is_reserved(job_id_path):
|
|
55
71
|
# reserved names is Windows specific
|
|
56
72
|
raise ValueError(f"Job ID '{job_id}' is a reserved name. Please choose a different name.")
|
|
57
73
|
|
|
@@ -68,7 +84,7 @@ class EvaluateRequest(BaseModel):
|
|
|
68
84
|
raise ValueError(f"Config file '{config_file}' must be a YAML file with one of the following extensions: "
|
|
69
85
|
f"{', '.join(YAML_EXTENSIONS)}")
|
|
70
86
|
|
|
71
|
-
if config_file_path
|
|
87
|
+
if _is_reserved(config_file_path):
|
|
72
88
|
# reserved names is Windows specific
|
|
73
89
|
raise ValueError(f"Config file '{config_file}' is a reserved name. Please choose a different name.")
|
|
74
90
|
|
|
@@ -50,46 +50,74 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
50
50
|
- Processor pipeline management (add, remove, clear)
|
|
51
51
|
- Type compatibility validation between processors
|
|
52
52
|
- Pipeline processing with error handling
|
|
53
|
+
- Configurable None filtering: processors returning None can drop items from pipeline
|
|
53
54
|
- Automatic type validation before export
|
|
54
55
|
"""
|
|
55
56
|
|
|
56
|
-
def __init__(self, context_state: ContextState | None = None):
|
|
57
|
+
def __init__(self, context_state: ContextState | None = None, drop_nones: bool = True):
|
|
57
58
|
"""Initialize the processing exporter.
|
|
58
59
|
|
|
59
60
|
Args:
|
|
60
|
-
context_state: The context state to use for the exporter.
|
|
61
|
+
context_state (ContextState | None): The context state to use for the exporter.
|
|
62
|
+
drop_nones (bool): Whether to drop items when processors return None (default: True).
|
|
61
63
|
"""
|
|
62
64
|
super().__init__(context_state)
|
|
63
65
|
self._processors: list[Processor] = [] # List of processors that implement process(item) -> item
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
self._processor_names: dict[str, int] = {} # Maps processor names to their positions
|
|
67
|
+
self._pipeline_locked: bool = False # Prevents modifications after startup
|
|
68
|
+
self._drop_nones: bool = drop_nones # Whether to drop None values between processors
|
|
69
|
+
|
|
70
|
+
def add_processor(self,
|
|
71
|
+
processor: Processor,
|
|
72
|
+
name: str | None = None,
|
|
73
|
+
position: int | None = None,
|
|
74
|
+
before: str | None = None,
|
|
75
|
+
after: str | None = None) -> None:
|
|
66
76
|
"""Add a processor to the processing pipeline.
|
|
67
77
|
|
|
68
|
-
Processors are executed in the order they are added.
|
|
69
|
-
|
|
78
|
+
Processors are executed in the order they are added. Processes can transform between any types (T -> U).
|
|
79
|
+
Supports flexible positioning using names, positions, or relative placement.
|
|
70
80
|
|
|
71
81
|
Args:
|
|
72
|
-
processor: The processor to add to the pipeline
|
|
82
|
+
processor (Processor): The processor to add to the pipeline
|
|
83
|
+
name (str | None): Name for the processor (for later reference). Must be unique.
|
|
84
|
+
position (int | None): Specific position to insert at (0-based index, -1 for append)
|
|
85
|
+
before (str | None): Insert before the named processor
|
|
86
|
+
after (str | None): Insert after the named processor
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
RuntimeError: If pipeline is locked (after startup)
|
|
90
|
+
ValueError: If positioning arguments conflict or named processor not found
|
|
73
91
|
"""
|
|
92
|
+
self._check_pipeline_locked()
|
|
74
93
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
# Determine insertion position
|
|
95
|
+
insert_position = self._calculate_insertion_position(position, before, after)
|
|
96
|
+
|
|
97
|
+
# Validate type compatibility at insertion point
|
|
98
|
+
self._validate_insertion_compatibility(processor, insert_position)
|
|
99
|
+
|
|
100
|
+
# Pre-validate name (no side effects yet)
|
|
101
|
+
if name is not None:
|
|
102
|
+
if not isinstance(name, str):
|
|
103
|
+
raise TypeError(f"Processor name must be a string, got {type(name).__name__}")
|
|
104
|
+
if name in self._processor_names:
|
|
105
|
+
raise ValueError(f"Processor name '{name}' already exists")
|
|
106
|
+
|
|
107
|
+
# Shift existing name positions (do this before list mutation)
|
|
108
|
+
for proc_name, pos in list(self._processor_names.items()):
|
|
109
|
+
if pos >= insert_position:
|
|
110
|
+
self._processor_names[proc_name] = pos + 1
|
|
111
|
+
|
|
112
|
+
# Insert the processor
|
|
113
|
+
if insert_position == len(self._processors):
|
|
114
|
+
self._processors.append(processor)
|
|
115
|
+
else:
|
|
116
|
+
self._processors.insert(insert_position, processor)
|
|
117
|
+
|
|
118
|
+
# Record the new processor name, if provided
|
|
119
|
+
if name is not None:
|
|
120
|
+
self._processor_names[name] = insert_position
|
|
93
121
|
|
|
94
122
|
# Set up pipeline continuation callback for processors that support it
|
|
95
123
|
if isinstance(processor, CallbackProcessor):
|
|
@@ -99,27 +127,231 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
99
127
|
|
|
100
128
|
processor.set_done_callback(pipeline_callback)
|
|
101
129
|
|
|
102
|
-
def remove_processor(self, processor: Processor) -> None:
|
|
130
|
+
def remove_processor(self, processor: Processor | str | int) -> None:
|
|
103
131
|
"""Remove a processor from the processing pipeline.
|
|
104
132
|
|
|
105
133
|
Args:
|
|
106
|
-
processor: The processor to remove
|
|
134
|
+
processor (Processor | str | int): The processor to remove (by name, position, or object).
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
RuntimeError: If pipeline is locked (after startup)
|
|
138
|
+
ValueError: If named processor or position not found
|
|
139
|
+
TypeError: If processor argument has invalid type
|
|
107
140
|
"""
|
|
108
|
-
|
|
109
|
-
|
|
141
|
+
self._check_pipeline_locked()
|
|
142
|
+
|
|
143
|
+
# Determine processor and position to remove
|
|
144
|
+
if isinstance(processor, str):
|
|
145
|
+
# Remove by name
|
|
146
|
+
if processor not in self._processor_names:
|
|
147
|
+
raise ValueError(f"Processor '{processor}' not found in pipeline")
|
|
148
|
+
position = self._processor_names[processor]
|
|
149
|
+
processor_obj = self._processors[position]
|
|
150
|
+
elif isinstance(processor, int):
|
|
151
|
+
# Remove by position
|
|
152
|
+
if not (0 <= processor < len(self._processors)):
|
|
153
|
+
raise ValueError(f"Position {processor} is out of range [0, {len(self._processors) - 1}]")
|
|
154
|
+
position = processor
|
|
155
|
+
processor_obj = self._processors[position]
|
|
156
|
+
elif isinstance(processor, Processor):
|
|
157
|
+
# Remove by object (existing behavior)
|
|
158
|
+
if processor not in self._processors:
|
|
159
|
+
return # Silently ignore if not found (existing behavior)
|
|
160
|
+
position = self._processors.index(processor)
|
|
161
|
+
processor_obj = processor
|
|
162
|
+
else:
|
|
163
|
+
raise TypeError(f"Processor must be a Processor object, string name, or int position, "
|
|
164
|
+
f"got {type(processor).__name__}")
|
|
165
|
+
|
|
166
|
+
# Remove the processor
|
|
167
|
+
self._processors.remove(processor_obj)
|
|
168
|
+
|
|
169
|
+
# Remove from name mapping and update positions
|
|
170
|
+
name_to_remove = None
|
|
171
|
+
for name, pos in self._processor_names.items():
|
|
172
|
+
if pos == position:
|
|
173
|
+
name_to_remove = name
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
if name_to_remove:
|
|
177
|
+
del self._processor_names[name_to_remove]
|
|
178
|
+
|
|
179
|
+
# Update positions for processors that shifted
|
|
180
|
+
for name, pos in self._processor_names.items():
|
|
181
|
+
if pos > position:
|
|
182
|
+
self._processor_names[name] = pos - 1
|
|
110
183
|
|
|
111
184
|
def clear_processors(self) -> None:
|
|
112
185
|
"""Clear all processors from the pipeline."""
|
|
186
|
+
self._check_pipeline_locked()
|
|
113
187
|
self._processors.clear()
|
|
188
|
+
self._processor_names.clear()
|
|
189
|
+
|
|
190
|
+
def reset_pipeline(self) -> None:
|
|
191
|
+
"""Reset the pipeline to allow modifications.
|
|
192
|
+
|
|
193
|
+
This unlocks the pipeline and clears all processors, allowing
|
|
194
|
+
the pipeline to be reconfigured. Can only be called when the
|
|
195
|
+
exporter is stopped.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
RuntimeError: If exporter is currently running
|
|
199
|
+
"""
|
|
200
|
+
if self._running:
|
|
201
|
+
raise RuntimeError("Cannot reset pipeline while exporter is running. "
|
|
202
|
+
"Call stop() first, then reset_pipeline().")
|
|
203
|
+
|
|
204
|
+
self._pipeline_locked = False
|
|
205
|
+
self._processors.clear()
|
|
206
|
+
self._processor_names.clear()
|
|
207
|
+
logger.debug("Pipeline reset - unlocked and cleared all processors")
|
|
208
|
+
|
|
209
|
+
def get_processor_by_name(self, name: str) -> Processor | None:
|
|
210
|
+
"""Get a processor by its name.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
name (str): The name of the processor to retrieve
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Processor | None: The processor with the given name, or None if not found
|
|
217
|
+
"""
|
|
218
|
+
if not isinstance(name, str):
|
|
219
|
+
raise TypeError(f"Processor name must be a string, got {type(name).__name__}")
|
|
220
|
+
if name in self._processor_names:
|
|
221
|
+
position = self._processor_names[name]
|
|
222
|
+
return self._processors[position]
|
|
223
|
+
logger.debug("Processor '%s' not found in pipeline", name)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def _check_pipeline_locked(self) -> None:
|
|
227
|
+
"""Check if pipeline is locked and raise error if it is."""
|
|
228
|
+
if self._pipeline_locked:
|
|
229
|
+
raise RuntimeError("Cannot modify processor pipeline after exporter has started. "
|
|
230
|
+
"Pipeline must be fully configured before calling start().")
|
|
231
|
+
|
|
232
|
+
def _calculate_insertion_position(self, position: int | None, before: str | None, after: str | None) -> int:
|
|
233
|
+
"""Calculate the insertion position based on provided arguments.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
position (int | None): Explicit position (0-based index, -1 for append)
|
|
237
|
+
before (str | None): Insert before this named processor
|
|
238
|
+
after (str | None): Insert after this named processor
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
int: The calculated insertion position
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
ValueError: If arguments conflict or named processor not found
|
|
245
|
+
"""
|
|
246
|
+
# Check for conflicting arguments
|
|
247
|
+
args_provided = sum(x is not None for x in [position, before, after])
|
|
248
|
+
if args_provided > 1:
|
|
249
|
+
raise ValueError("Only one of position, before, or after can be specified")
|
|
250
|
+
|
|
251
|
+
# Default to append
|
|
252
|
+
if args_provided == 0:
|
|
253
|
+
return len(self._processors)
|
|
254
|
+
|
|
255
|
+
# Handle explicit position
|
|
256
|
+
if position is not None:
|
|
257
|
+
if position == -1:
|
|
258
|
+
return len(self._processors)
|
|
259
|
+
if 0 <= position <= len(self._processors):
|
|
260
|
+
return position
|
|
261
|
+
raise ValueError(f"Position {position} is out of range [0, {len(self._processors)}]")
|
|
262
|
+
|
|
263
|
+
# Handle before/after named processors
|
|
264
|
+
if before is not None:
|
|
265
|
+
if not isinstance(before, str):
|
|
266
|
+
raise TypeError(f"'before' parameter must be a string, got {type(before).__name__}")
|
|
267
|
+
if before not in self._processor_names:
|
|
268
|
+
raise ValueError(f"Processor '{before}' not found in pipeline")
|
|
269
|
+
return self._processor_names[before]
|
|
270
|
+
|
|
271
|
+
if after is not None:
|
|
272
|
+
if not isinstance(after, str):
|
|
273
|
+
raise TypeError(f"'after' parameter must be a string, got {type(after).__name__}")
|
|
274
|
+
if after not in self._processor_names:
|
|
275
|
+
raise ValueError(f"Processor '{after}' not found in pipeline")
|
|
276
|
+
return self._processor_names[after] + 1
|
|
277
|
+
|
|
278
|
+
# Should never reach here
|
|
279
|
+
return len(self._processors)
|
|
280
|
+
|
|
281
|
+
def _validate_insertion_compatibility(self, processor: Processor, position: int) -> None:
|
|
282
|
+
"""Validate type compatibility for processor insertion.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
processor (Processor): The processor to insert
|
|
286
|
+
position (int): The position where it will be inserted
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: If processor is not compatible with neighbors
|
|
290
|
+
"""
|
|
291
|
+
# Check compatibility with neighbors
|
|
292
|
+
if position > 0:
|
|
293
|
+
predecessor = self._processors[position - 1]
|
|
294
|
+
self._check_processor_compatibility(predecessor,
|
|
295
|
+
processor,
|
|
296
|
+
"predecessor",
|
|
297
|
+
predecessor.output_class,
|
|
298
|
+
processor.input_class,
|
|
299
|
+
str(predecessor.output_type),
|
|
300
|
+
str(processor.input_type))
|
|
301
|
+
|
|
302
|
+
if position < len(self._processors):
|
|
303
|
+
successor = self._processors[position]
|
|
304
|
+
self._check_processor_compatibility(processor,
|
|
305
|
+
successor,
|
|
306
|
+
"successor",
|
|
307
|
+
processor.output_class,
|
|
308
|
+
successor.input_class,
|
|
309
|
+
str(processor.output_type),
|
|
310
|
+
str(successor.input_type))
|
|
311
|
+
|
|
312
|
+
def _check_processor_compatibility(self,
|
|
313
|
+
source_processor: Processor,
|
|
314
|
+
target_processor: Processor,
|
|
315
|
+
relationship: str,
|
|
316
|
+
source_class: type,
|
|
317
|
+
target_class: type,
|
|
318
|
+
source_type: str,
|
|
319
|
+
target_type: str) -> None:
|
|
320
|
+
"""Check type compatibility between two processors.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
source_processor (Processor): The processor providing output
|
|
324
|
+
target_processor (Processor): The processor receiving input
|
|
325
|
+
relationship (str): Description of relationship ("predecessor" or "successor")
|
|
326
|
+
source_class (type): The output class of source processor
|
|
327
|
+
target_class (type): The input class of target processor
|
|
328
|
+
source_type (str): String representation of source type
|
|
329
|
+
target_type (str): String representation of target type
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
if not issubclass(source_class, target_class):
|
|
333
|
+
raise ValueError(f"Processor {target_processor.__class__.__name__} input type {target_type} "
|
|
334
|
+
f"is not compatible with {relationship} {source_processor.__class__.__name__} "
|
|
335
|
+
f"output type {source_type}")
|
|
336
|
+
except TypeError:
|
|
337
|
+
logger.warning(
|
|
338
|
+
"Cannot use issubclass() for type compatibility check between "
|
|
339
|
+
"%s (%s) and %s (%s). Skipping compatibility check.",
|
|
340
|
+
source_processor.__class__.__name__,
|
|
341
|
+
source_type,
|
|
342
|
+
target_processor.__class__.__name__,
|
|
343
|
+
target_type)
|
|
114
344
|
|
|
115
345
|
async def _pre_start(self) -> None:
|
|
346
|
+
|
|
347
|
+
# Validate that the pipeline is compatible with the exporter
|
|
116
348
|
if len(self._processors) > 0:
|
|
117
349
|
first_processor = self._processors[0]
|
|
118
350
|
last_processor = self._processors[-1]
|
|
119
351
|
|
|
120
352
|
# validate that the first processor's input type is compatible with the exporter's input type
|
|
121
353
|
try:
|
|
122
|
-
if not issubclass(
|
|
354
|
+
if not issubclass(self.input_class, first_processor.input_class):
|
|
123
355
|
raise ValueError(f"Processor {first_processor.__class__.__name__} input type "
|
|
124
356
|
f"{first_processor.input_type} is not compatible with the "
|
|
125
357
|
f"{self.input_type} input type")
|
|
@@ -149,14 +381,17 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
149
381
|
self.output_type,
|
|
150
382
|
e)
|
|
151
383
|
|
|
152
|
-
|
|
384
|
+
# Lock the pipeline to prevent further modifications
|
|
385
|
+
self._pipeline_locked = True
|
|
386
|
+
|
|
387
|
+
async def _process_pipeline(self, item: PipelineInputT) -> PipelineOutputT | None:
|
|
153
388
|
"""Process item through all registered processors.
|
|
154
389
|
|
|
155
390
|
Args:
|
|
156
391
|
item (PipelineInputT): The item to process (starts as PipelineInputT, can transform to PipelineOutputT)
|
|
157
392
|
|
|
158
393
|
Returns:
|
|
159
|
-
PipelineOutputT: The processed item after running through all processors
|
|
394
|
+
PipelineOutputT | None: The processed item after running through all processors
|
|
160
395
|
"""
|
|
161
396
|
return await self._process_through_processors(self._processors, item) # type: ignore
|
|
162
397
|
|
|
@@ -168,12 +403,18 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
168
403
|
item (Any): The item to process
|
|
169
404
|
|
|
170
405
|
Returns:
|
|
171
|
-
The processed item after running through all processors
|
|
406
|
+
Any: The processed item after running through all processors, or None if
|
|
407
|
+
drop_nones is True and any processor returned None
|
|
172
408
|
"""
|
|
173
409
|
processed_item = item
|
|
174
410
|
for processor in processors:
|
|
175
411
|
try:
|
|
176
412
|
processed_item = await processor.process(processed_item)
|
|
413
|
+
# Drop None values between processors if configured to do so
|
|
414
|
+
if self._drop_nones and processed_item is None:
|
|
415
|
+
logger.debug("Processor %s returned None, dropping item from pipeline",
|
|
416
|
+
processor.__class__.__name__)
|
|
417
|
+
return None
|
|
177
418
|
except Exception as e:
|
|
178
419
|
logger.exception("Error in processor %s: %s", processor.__class__.__name__, e)
|
|
179
420
|
# Continue with unprocessed item rather than failing
|
|
@@ -221,6 +462,11 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
221
462
|
remaining_processors = self._processors[source_index + 1:]
|
|
222
463
|
processed_item = await self._process_through_processors(remaining_processors, item)
|
|
223
464
|
|
|
465
|
+
# Skip export if remaining pipeline dropped the item (returned None)
|
|
466
|
+
if processed_item is None:
|
|
467
|
+
logger.debug("Item was dropped by remaining processor pipeline, skipping export")
|
|
468
|
+
return
|
|
469
|
+
|
|
224
470
|
# Export the final result
|
|
225
471
|
await self._export_final_item(processed_item)
|
|
226
472
|
|
|
@@ -233,11 +479,16 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
233
479
|
"""Export an item after processing it through the pipeline.
|
|
234
480
|
|
|
235
481
|
Args:
|
|
236
|
-
item: The item to export
|
|
482
|
+
item (PipelineInputT): The item to export
|
|
237
483
|
"""
|
|
238
484
|
try:
|
|
239
485
|
# Then, run through the processor pipeline
|
|
240
|
-
final_item: PipelineOutputT = await self._process_pipeline(item)
|
|
486
|
+
final_item: PipelineOutputT | None = await self._process_pipeline(item)
|
|
487
|
+
|
|
488
|
+
# Skip export if pipeline dropped the item (returned None)
|
|
489
|
+
if final_item is None:
|
|
490
|
+
logger.debug("Item was dropped by processor pipeline, skipping export")
|
|
491
|
+
return
|
|
241
492
|
|
|
242
493
|
# Handle different output types from batch processors
|
|
243
494
|
if isinstance(final_item, list) and len(final_item) == 0:
|
|
@@ -276,12 +527,16 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
276
527
|
the actual export logic after the item has been processed through the pipeline.
|
|
277
528
|
|
|
278
529
|
Args:
|
|
279
|
-
item: The processed item to export (PipelineOutputT type)
|
|
530
|
+
item (PipelineOutputT | list[PipelineOutputT]): The processed item to export (PipelineOutputT type)
|
|
280
531
|
"""
|
|
281
532
|
pass
|
|
282
533
|
|
|
283
|
-
def _create_export_task(self, coro: Coroutine):
|
|
284
|
-
"""Create task with minimal overhead but proper tracking.
|
|
534
|
+
def _create_export_task(self, coro: Coroutine) -> None:
|
|
535
|
+
"""Create task with minimal overhead but proper tracking.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
coro: The coroutine to create a task for
|
|
539
|
+
"""
|
|
285
540
|
if not self._running:
|
|
286
541
|
logger.warning("%s: Attempted to create export task while not running", self.name)
|
|
287
542
|
return
|
|
@@ -296,7 +551,7 @@ class ProcessingExporter(Generic[PipelineInputT, PipelineOutputT], BaseExporter,
|
|
|
296
551
|
raise
|
|
297
552
|
|
|
298
553
|
@override
|
|
299
|
-
async def _cleanup(self):
|
|
554
|
+
async def _cleanup(self) -> None:
|
|
300
555
|
"""Enhanced cleanup that shuts down all shutdown-aware processors.
|
|
301
556
|
|
|
302
557
|
Each processor is responsible for its own cleanup, including routing
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
from pydantic import BaseModel
|
|
17
|
+
from pydantic import Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RedactionConfigMixin(BaseModel):
|
|
21
|
+
"""Mixin for basic redaction configuration.
|
|
22
|
+
|
|
23
|
+
Provides core redaction functionality that can be used standalone
|
|
24
|
+
or inherited by specialized redaction mixins.
|
|
25
|
+
"""
|
|
26
|
+
redaction_enabled: bool = Field(default=False, description="Whether to enable redaction processing.")
|
|
27
|
+
redaction_value: str = Field(default="[REDACTED]", description="Value to replace redacted attributes with.")
|
|
28
|
+
redaction_attributes: list[str] = Field(default_factory=lambda: ["input.value", "output.value", "metadata"],
|
|
29
|
+
description="Span attributes to redact when redaction is triggered.")
|
|
30
|
+
force_redaction: bool = Field(default=False, description="Always redact regardless of other conditions.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HeaderRedactionConfigMixin(RedactionConfigMixin):
|
|
34
|
+
"""Mixin for header-based redaction configuration.
|
|
35
|
+
|
|
36
|
+
Inherits core redaction fields (redaction_enabled, redaction_attributes, force_redaction)
|
|
37
|
+
and adds header-specific configuration for authentication-based redaction decisions.
|
|
38
|
+
|
|
39
|
+
Note: The callback function must be provided directly to the processor at runtime.
|
|
40
|
+
"""
|
|
41
|
+
redaction_header: str = Field(default="x-redaction-key", description="Header to check for redaction decisions.")
|