reactor-runtime 2.7.7__tar.gz → 2.7.8__tar.gz
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.
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/PKG-INFO +1 -1
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/pyproject.toml +1 -1
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/config.py +36 -1
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/defaults.py +12 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtime_api.py +252 -1
- reactor_runtime-2.7.8/src/reactor_runtime/schema_validator.py +231 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/messages.py +4 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/PKG-INFO +1 -1
- reactor_runtime-2.7.7/src/reactor_runtime/schema_validator.py +0 -123
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/README.md +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/setup.cfg +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/api/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/experiment/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/experiment/session.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/driver/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/driver/pipeline_executor.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/driver/step_result.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/connected.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/event.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/messages.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/upload.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/output_buffer.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/decorators.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/handlers.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/input.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/output.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/upload.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/model_state.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/base.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/file.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/otlp.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/helpers.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/nvml_sampler.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/plotting/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/plotting/plot_profiling.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/profiler.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/singleton.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/torch_chunk_profiler.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/chunk_uploader.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/config.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/markers.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/session_recorder.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/sinks.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/track_resolver.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/headless/config.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/headless/headless_runtime.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/headless/input_feeder.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/http/config.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/http/http_runtime.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/http/types.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/schema.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/__main__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/commands/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/commands/run.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/commands/schema.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/main.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/utils/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/utils/config.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/utils/runtime.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/audio_track.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/client.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/frame_conversion.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/ice_connection.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/video_track.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/config.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/events.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/client.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/av1.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/base.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/factory.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/h264.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/h265.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/opus.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/vp8.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/vp9.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/av1.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/base.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/factory.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/h264.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/h265.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/opus.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/vp8.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/vp9.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/gst.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/gst_helpers.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/probes/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/probes/fps_probe.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/audio.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/base.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/video.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/bundle.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/codec.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/extmap.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/ice.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/__init__.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/audio.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/base.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/video.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/settings.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/signals.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/ice_uris.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/interface.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/media.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/types.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/launch.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/loader.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/log.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/paths.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/ports.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/typing.py +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/SOURCES.txt +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/dependency_links.txt +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/requires.txt +0 -0
- {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/top_level.txt +0 -0
|
@@ -81,6 +81,37 @@ def _reset_legacy_warning_for_tests() -> None:
|
|
|
81
81
|
_legacy_warned = False
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
@dataclass
|
|
85
|
+
class ModerationYamlConfig:
|
|
86
|
+
"""Top-level ``moderation:`` block parsed from ``reactor.yaml``.
|
|
87
|
+
|
|
88
|
+
Model authors declare here whether content moderation runs for their
|
|
89
|
+
model. Defaults to ``enabled: false`` so existing models opt in
|
|
90
|
+
explicitly. Operational tuning (thresholds, concurrency, backend
|
|
91
|
+
choice, ``OPENAI_API_KEY``) stays in environment variables — this
|
|
92
|
+
block carries only the policy decision the model author owns.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
enabled: bool = False
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, raw: Optional[Dict[str, Any]]) -> "ModerationYamlConfig":
|
|
99
|
+
if not isinstance(raw, dict):
|
|
100
|
+
return cls()
|
|
101
|
+
enabled = raw.get("enabled", False)
|
|
102
|
+
# Strict bool check: `bool("false") is True` would silently flip
|
|
103
|
+
# moderation on for a YAML author who wrote `enabled: "false"`
|
|
104
|
+
# (quoted) or templated a `"0"` value. Safety-sensitive config —
|
|
105
|
+
# fail at parse time with a message that names the offending value.
|
|
106
|
+
if not isinstance(enabled, bool):
|
|
107
|
+
raise TypeError(
|
|
108
|
+
f"moderation.enabled in reactor.yaml must be a boolean, "
|
|
109
|
+
f"got {type(enabled).__name__}: {enabled!r}. "
|
|
110
|
+
'Did you accidentally quote the value (e.g. "false")?'
|
|
111
|
+
)
|
|
112
|
+
return cls(enabled=enabled)
|
|
113
|
+
|
|
114
|
+
|
|
84
115
|
@dataclass
|
|
85
116
|
class ReactorConfig:
|
|
86
117
|
"""Parsed ``reactor.yaml``.
|
|
@@ -119,6 +150,7 @@ class ReactorConfig:
|
|
|
119
150
|
config_overrides: List[str] = field(default_factory=list)
|
|
120
151
|
weights_path: Optional[str] = None
|
|
121
152
|
recording: RecordingConfig = field(default_factory=RecordingConfig)
|
|
153
|
+
moderation: ModerationYamlConfig = field(default_factory=ModerationYamlConfig)
|
|
122
154
|
|
|
123
155
|
@classmethod
|
|
124
156
|
def from_dict(cls, raw: Dict[str, Any]) -> "ReactorConfig":
|
|
@@ -135,8 +167,9 @@ class ReactorConfig:
|
|
|
135
167
|
runtime_section = raw.get("runtime")
|
|
136
168
|
model_section = raw.get("model")
|
|
137
169
|
|
|
138
|
-
# Recording
|
|
170
|
+
# Recording and moderation are top-level siblings to runtime: / model:.
|
|
139
171
|
recording = RecordingConfig.from_dict(raw.get("recording"))
|
|
172
|
+
moderation = ModerationYamlConfig.from_dict(raw.get("moderation"))
|
|
140
173
|
|
|
141
174
|
modern = isinstance(runtime_section, dict) or isinstance(model_section, dict)
|
|
142
175
|
|
|
@@ -161,6 +194,7 @@ class ReactorConfig:
|
|
|
161
194
|
config=runtime_section.get("config"),
|
|
162
195
|
weights_path=runtime_section.get("weights_path") or None,
|
|
163
196
|
recording=recording,
|
|
197
|
+
moderation=moderation,
|
|
164
198
|
)
|
|
165
199
|
|
|
166
200
|
if "model" not in raw:
|
|
@@ -177,4 +211,5 @@ class ReactorConfig:
|
|
|
177
211
|
config=raw.get("config"),
|
|
178
212
|
weights_path=raw.get("weights_path") or None,
|
|
179
213
|
recording=recording,
|
|
214
|
+
moderation=moderation,
|
|
180
215
|
)
|
|
@@ -51,6 +51,7 @@ class FieldInfo:
|
|
|
51
51
|
min_length: Optional[int] = None
|
|
52
52
|
max_length: Optional[int] = None
|
|
53
53
|
choices: Optional[List[Any]] = None
|
|
54
|
+
moderate: bool = True
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
def InputField(
|
|
@@ -63,6 +64,7 @@ def InputField(
|
|
|
63
64
|
min_length: Optional[int] = None,
|
|
64
65
|
max_length: Optional[int] = None,
|
|
65
66
|
choices: Optional[List[Any]] = None,
|
|
67
|
+
moderate: bool = True,
|
|
66
68
|
) -> Any:
|
|
67
69
|
"""Declare a default value and validation constraints for a field.
|
|
68
70
|
|
|
@@ -81,6 +83,13 @@ def InputField(
|
|
|
81
83
|
min_length: Minimum length for string/sequence values.
|
|
82
84
|
max_length: Maximum length for string/sequence values.
|
|
83
85
|
choices: Exhaustive list of allowed values.
|
|
86
|
+
moderate: Whether to submit this field's value to the content
|
|
87
|
+
moderation backend (when moderation is enabled platform-wide).
|
|
88
|
+
Defaults to ``True``. Only effective for free-text ``str``
|
|
89
|
+
fields (those without ``choices=``) and ``UploadedFile``
|
|
90
|
+
fields — typed, enum, and numeric fields are never moderated
|
|
91
|
+
because the schema validator rejects out-of-set payloads
|
|
92
|
+
before they reach the model.
|
|
84
93
|
"""
|
|
85
94
|
if default_factory is not None:
|
|
86
95
|
raise TypeError(
|
|
@@ -95,6 +104,7 @@ def InputField(
|
|
|
95
104
|
min_length=min_length,
|
|
96
105
|
max_length=max_length,
|
|
97
106
|
choices=choices,
|
|
107
|
+
moderate=moderate,
|
|
98
108
|
)
|
|
99
109
|
|
|
100
110
|
|
|
@@ -227,3 +237,5 @@ def field_info_to_json_schema(schema: Dict[str, Any], info: FieldInfo) -> None:
|
|
|
227
237
|
schema["maxLength"] = info.max_length
|
|
228
238
|
if info.choices is not None:
|
|
229
239
|
schema["enum"] = info.choices
|
|
240
|
+
if not info.moderate:
|
|
241
|
+
schema["x-reactor-moderate"] = False
|
|
@@ -14,7 +14,7 @@ import threading
|
|
|
14
14
|
import uuid
|
|
15
15
|
from abc import ABC, abstractmethod
|
|
16
16
|
from dataclasses import dataclass, field
|
|
17
|
-
from typing import TYPE_CHECKING, Optional
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Iterator, Optional
|
|
18
18
|
|
|
19
19
|
from reactor_runtime.schema import ModelSchema
|
|
20
20
|
from reactor_runtime.config import ReactorConfig
|
|
@@ -42,8 +42,61 @@ from reactor_runtime.utils.messages import (
|
|
|
42
42
|
)
|
|
43
43
|
from reactor_runtime.utils.log import get_logger
|
|
44
44
|
|
|
45
|
+
# Content moderation is internal-only and stripped from the OSS PyPI release
|
|
46
|
+
# by ``obfuscate.py`` (the leading underscore on the ``_moderation`` package
|
|
47
|
+
# matches the convention used by ``_redis``, ``_proto``, etc.). OSS builds
|
|
48
|
+
# fall through to ``self._moderator = None`` at runtime startup and emit a
|
|
49
|
+
# WARN so operators don't run unmoderated by accident.
|
|
50
|
+
try:
|
|
51
|
+
from reactor_runtime._moderation import (
|
|
52
|
+
ContentModerator,
|
|
53
|
+
ModerationResult,
|
|
54
|
+
ModerationTier,
|
|
55
|
+
SessionModerator,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_MODERATION_AVAILABLE = True
|
|
59
|
+
except ImportError: # pragma: no cover — OSS build path
|
|
60
|
+
ContentModerator = None # type: ignore[misc,assignment]
|
|
61
|
+
ModerationResult = None # type: ignore[misc,assignment]
|
|
62
|
+
ModerationTier = None # type: ignore[misc,assignment]
|
|
63
|
+
SessionModerator = None # type: ignore[misc,assignment]
|
|
64
|
+
_MODERATION_AVAILABLE = False
|
|
65
|
+
|
|
45
66
|
logger = get_logger(__name__)
|
|
46
67
|
|
|
68
|
+
|
|
69
|
+
def _iter_strings(value: Any) -> Iterator[str]:
|
|
70
|
+
"""Yield every non-empty string leaf from a possibly-nested payload.
|
|
71
|
+
|
|
72
|
+
Used by :meth:`Runtime._schedule_moderation_for_event` to walk a
|
|
73
|
+
moderatable field's validated value. Top-level ``str`` is the easy
|
|
74
|
+
case; the recursion handles structured payloads where user text
|
|
75
|
+
can be nested — ``List[str]``, ``Dict[str, str]``, dataclass-shaped
|
|
76
|
+
args like ``List[ChatTurn]`` (deserialized as ``list[dict]``).
|
|
77
|
+
Without this, a model that opts into moderation could still be
|
|
78
|
+
bypassed by wrapping user text inside any structured argument —
|
|
79
|
+
the codex P1 finding on PR #2533.
|
|
80
|
+
|
|
81
|
+
``UploadedFile`` instances are skipped here; uploads have their own
|
|
82
|
+
dispatch path (:meth:`get_moderate_upload_fields`) and would
|
|
83
|
+
otherwise double-fire on the bytes.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(value, UploadedFile):
|
|
86
|
+
return
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
if value:
|
|
89
|
+
yield value
|
|
90
|
+
return
|
|
91
|
+
if isinstance(value, dict):
|
|
92
|
+
for sub in value.values():
|
|
93
|
+
yield from _iter_strings(sub)
|
|
94
|
+
return
|
|
95
|
+
if isinstance(value, (list, tuple)):
|
|
96
|
+
for item in value:
|
|
97
|
+
yield from _iter_strings(item)
|
|
98
|
+
|
|
99
|
+
|
|
47
100
|
_IDLING_INTERVAL_ENV_VAR = "IDLING_INTERVAL_SECONDS"
|
|
48
101
|
_DEFAULT_IDLING_INTERVAL = 30.0
|
|
49
102
|
|
|
@@ -256,6 +309,45 @@ class Runtime(ModelStateMachine, ABC):
|
|
|
256
309
|
self._shutdown_event: asyncio.Event = asyncio.Event()
|
|
257
310
|
self._setup_signal_handlers()
|
|
258
311
|
|
|
312
|
+
# Content moderation. The on/off decision lives in
|
|
313
|
+
# ``reactor.yaml`` under ``moderation.enabled``; operational
|
|
314
|
+
# knobs and the backend API key come from env. ``None`` when the
|
|
315
|
+
# model opted out or when running an OSS build that has the
|
|
316
|
+
# ``_moderation`` package stripped. Every injection point guards
|
|
317
|
+
# with ``if self._moderator is not None``.
|
|
318
|
+
#
|
|
319
|
+
# Misconfiguration is fail-loud: if the model has declared it
|
|
320
|
+
# needs moderation (``moderation.enabled: true`` in reactor.yaml)
|
|
321
|
+
# but either the operational env is missing required secrets or
|
|
322
|
+
# the ``_moderation`` package isn't available (OSS build), the
|
|
323
|
+
# runtime refuses to start rather than silently admitting
|
|
324
|
+
# unmoderated input.
|
|
325
|
+
self._moderator: Optional["ContentModerator"] = None
|
|
326
|
+
self._session_moderator: Optional["SessionModerator"] = None
|
|
327
|
+
moderation_enabled = self.config.reactor_config.moderation.enabled
|
|
328
|
+
if _MODERATION_AVAILABLE:
|
|
329
|
+
self._moderator = ContentModerator.from_reactor_config(
|
|
330
|
+
self.config.reactor_config
|
|
331
|
+
)
|
|
332
|
+
if self._moderator is not None:
|
|
333
|
+
logger.info("Content moderation enabled")
|
|
334
|
+
else:
|
|
335
|
+
logger.info(
|
|
336
|
+
"Content moderation disabled "
|
|
337
|
+
"(moderation.enabled is not set in reactor.yaml)"
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
if moderation_enabled:
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
"reactor.yaml has 'moderation.enabled: true' but the "
|
|
343
|
+
"_moderation package is not importable in this build "
|
|
344
|
+
"(stripped OSS release or transitive import failure). "
|
|
345
|
+
"Refusing to start unmoderated — set "
|
|
346
|
+
"'moderation.enabled: false' in reactor.yaml or use an "
|
|
347
|
+
"internal build that includes the _moderation module."
|
|
348
|
+
)
|
|
349
|
+
logger.debug("Content moderation module not present in this build (OSS)")
|
|
350
|
+
|
|
259
351
|
# Start idling loop (runs continuously, only sends events when in READY state)
|
|
260
352
|
self._start_idling_loop()
|
|
261
353
|
|
|
@@ -443,6 +535,14 @@ class Runtime(ModelStateMachine, ABC):
|
|
|
443
535
|
self.model._push_event(Disconnected())
|
|
444
536
|
logger.info("Pushed Disconnected event to model")
|
|
445
537
|
|
|
538
|
+
# Cancel any in-flight moderation tasks for this session — their
|
|
539
|
+
# enforcement target (this session) is going away regardless of
|
|
540
|
+
# the outcome.
|
|
541
|
+
sm = self._session_moderator
|
|
542
|
+
if sm is not None:
|
|
543
|
+
self._session_moderator = None
|
|
544
|
+
asyncio.create_task(sm.shutdown())
|
|
545
|
+
|
|
446
546
|
self.loop.call_soon_threadsafe(self.send, ModelEvent.CLEANUP_COMPLETE)
|
|
447
547
|
|
|
448
548
|
# -----------------------------------------------------------------
|
|
@@ -460,6 +560,124 @@ class Runtime(ModelStateMachine, ABC):
|
|
|
460
560
|
self._schema_validator = SchemaValidator(schema.to_openapi())
|
|
461
561
|
return self._schema_validator
|
|
462
562
|
|
|
563
|
+
# -----------------------------------------------------------------
|
|
564
|
+
# Moderation helpers
|
|
565
|
+
# -----------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
def _get_session_moderator(self) -> Optional["SessionModerator"]:
|
|
568
|
+
"""Lazily build a per-session moderator bag for in-flight tasks.
|
|
569
|
+
|
|
570
|
+
Returns ``None`` when moderation is disabled, so callers can pass
|
|
571
|
+
the result through ``if`` without needing a separate enabled
|
|
572
|
+
check. The instance is cleared in :meth:`on_enter_CLOSING` so
|
|
573
|
+
each session gets a fresh failure counter.
|
|
574
|
+
"""
|
|
575
|
+
if self._moderator is None:
|
|
576
|
+
return None
|
|
577
|
+
if self._session_moderator is None:
|
|
578
|
+
session_id = getattr(self, "session_id", None) or ""
|
|
579
|
+
self._session_moderator = self._moderator.for_session(session_id)
|
|
580
|
+
return self._session_moderator
|
|
581
|
+
|
|
582
|
+
def _schedule_moderation_for_event(
|
|
583
|
+
self,
|
|
584
|
+
cmd_name: str,
|
|
585
|
+
validated: dict,
|
|
586
|
+
validator: SchemaValidator,
|
|
587
|
+
) -> None:
|
|
588
|
+
"""Fire async moderation for any moderatable fields on *validated*."""
|
|
589
|
+
sm = self._get_session_moderator()
|
|
590
|
+
if sm is None:
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
for field_name in validator.get_moderate_text_fields(cmd_name):
|
|
594
|
+
value = validated.get(field_name)
|
|
595
|
+
# Recurse the value so List[str] / Dict[str, str] / dataclass
|
|
596
|
+
# payloads can't bypass moderation by wrapping user text in a
|
|
597
|
+
# structured argument (codex P1). See `_iter_strings`.
|
|
598
|
+
for text in _iter_strings(value):
|
|
599
|
+
sm.schedule_text_check(
|
|
600
|
+
text, command=cmd_name, on_result=self._enforce_moderation
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
for field_name in validator.get_moderate_upload_fields(cmd_name):
|
|
604
|
+
value = validated.get(field_name)
|
|
605
|
+
if not isinstance(value, UploadedFile):
|
|
606
|
+
continue
|
|
607
|
+
if value.mime_type.startswith("image/"):
|
|
608
|
+
sm.schedule_image_check(
|
|
609
|
+
value.data,
|
|
610
|
+
value.mime_type,
|
|
611
|
+
command=cmd_name,
|
|
612
|
+
on_result=self._enforce_moderation,
|
|
613
|
+
)
|
|
614
|
+
elif value.mime_type.startswith("text/"):
|
|
615
|
+
try:
|
|
616
|
+
text = value.data.decode("utf-8", errors="replace")
|
|
617
|
+
except Exception:
|
|
618
|
+
text = ""
|
|
619
|
+
if text:
|
|
620
|
+
sm.schedule_text_check(
|
|
621
|
+
text, command=cmd_name, on_result=self._enforce_moderation
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
async def _enforce_moderation(
|
|
625
|
+
self,
|
|
626
|
+
tier: "ModerationTier",
|
|
627
|
+
result: "ModerationResult",
|
|
628
|
+
input_kind: str,
|
|
629
|
+
command: str,
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Send a moderation runtime message and, on terminate, stop the session.
|
|
632
|
+
|
|
633
|
+
Bound as the ``on_result`` callback on every scheduled check. The
|
|
634
|
+
50ms ``asyncio.sleep`` before ``STOP_SESSION`` gives the data
|
|
635
|
+
channel a window to flush the moderation message to the client —
|
|
636
|
+
without it the client sees a bare disconnect.
|
|
637
|
+
"""
|
|
638
|
+
flagged_categories = sorted(c for c, v in result.categories.items() if v)
|
|
639
|
+
action = tier.value
|
|
640
|
+
message_text = (
|
|
641
|
+
"Session terminated due to policy violation."
|
|
642
|
+
if tier is ModerationTier.TERMINATE
|
|
643
|
+
else "Content was flagged for potential policy violations."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
wrapped = RuntimeMessage(
|
|
647
|
+
data={
|
|
648
|
+
"type": RuntimeMessageType.MODERATION,
|
|
649
|
+
"data": {
|
|
650
|
+
"action": action,
|
|
651
|
+
"input_kind": input_kind,
|
|
652
|
+
"command": command,
|
|
653
|
+
"categories": flagged_categories,
|
|
654
|
+
"message": message_text,
|
|
655
|
+
},
|
|
656
|
+
}
|
|
657
|
+
).model_dump()
|
|
658
|
+
await self.send_out_runtime_message(wrapped)
|
|
659
|
+
|
|
660
|
+
if tier is ModerationTier.TERMINATE:
|
|
661
|
+
logger.error(
|
|
662
|
+
"Content moderation termination",
|
|
663
|
+
action=action,
|
|
664
|
+
input_kind=input_kind,
|
|
665
|
+
command=command,
|
|
666
|
+
categories=flagged_categories,
|
|
667
|
+
)
|
|
668
|
+
# Brief window to let the runtime message flush before the
|
|
669
|
+
# transport tears down.
|
|
670
|
+
await asyncio.sleep(0.05)
|
|
671
|
+
self.send(ModelEvent.STOP_SESSION)
|
|
672
|
+
else:
|
|
673
|
+
logger.warning(
|
|
674
|
+
"Content moderation warning",
|
|
675
|
+
action=action,
|
|
676
|
+
input_kind=input_kind,
|
|
677
|
+
command=command,
|
|
678
|
+
categories=flagged_categories,
|
|
679
|
+
)
|
|
680
|
+
|
|
463
681
|
# -----------------------------------------------------------------
|
|
464
682
|
# Inbound data channel messages
|
|
465
683
|
# -----------------------------------------------------------------
|
|
@@ -666,6 +884,11 @@ class Runtime(ModelStateMachine, ABC):
|
|
|
666
884
|
|
|
667
885
|
self.model._push_event(event)
|
|
668
886
|
|
|
887
|
+
# Schedule async moderation of any moderatable fields on the event.
|
|
888
|
+
# Fire-and-forget — the model already has the input; enforcement is
|
|
889
|
+
# post-hoc per the design.
|
|
890
|
+
self._schedule_moderation_for_event(cmd_name, validated, validator)
|
|
891
|
+
|
|
669
892
|
async def _handle_file_uploaded(self, payload: dict) -> None:
|
|
670
893
|
"""Download an uploaded file and push a FileUploaded event to the model.
|
|
671
894
|
|
|
@@ -705,6 +928,28 @@ class Runtime(ModelStateMachine, ABC):
|
|
|
705
928
|
size=size,
|
|
706
929
|
)
|
|
707
930
|
|
|
931
|
+
# Ad-hoc file uploads (no command name) always default to moderated;
|
|
932
|
+
# the per-field ``moderate=False`` opt-out lives on ``InputField``
|
|
933
|
+
# declarations and doesn't apply here. The originating command is
|
|
934
|
+
# synthesised as ``"fileUploaded"`` for logging/categorisation.
|
|
935
|
+
sm = self._get_session_moderator()
|
|
936
|
+
if sm is not None and mime_type.startswith("image/"):
|
|
937
|
+
sm.schedule_image_check(
|
|
938
|
+
data,
|
|
939
|
+
mime_type,
|
|
940
|
+
command="fileUploaded",
|
|
941
|
+
on_result=self._enforce_moderation,
|
|
942
|
+
)
|
|
943
|
+
elif sm is not None and mime_type.startswith("text/"):
|
|
944
|
+
try:
|
|
945
|
+
text = data.decode("utf-8", errors="replace")
|
|
946
|
+
except Exception:
|
|
947
|
+
text = ""
|
|
948
|
+
if text:
|
|
949
|
+
sm.schedule_text_check(
|
|
950
|
+
text, command="fileUploaded", on_result=self._enforce_moderation
|
|
951
|
+
)
|
|
952
|
+
|
|
708
953
|
# -----------------------------------------------------------------
|
|
709
954
|
# Trickle ICE (transport-agnostic)
|
|
710
955
|
# -----------------------------------------------------------------
|
|
@@ -990,6 +1235,12 @@ class Runtime(ModelStateMachine, ABC):
|
|
|
990
1235
|
except Exception as e:
|
|
991
1236
|
logger.warning("Failed to shutdown profiler", error=e)
|
|
992
1237
|
|
|
1238
|
+
if self._moderator is not None:
|
|
1239
|
+
try:
|
|
1240
|
+
await self._moderator.close()
|
|
1241
|
+
except Exception as e:
|
|
1242
|
+
logger.warning("Failed to close content moderator", error=e)
|
|
1243
|
+
|
|
993
1244
|
if self.current_state == ModelState.READY:
|
|
994
1245
|
self.send(ModelEvent.EVICTION)
|
|
995
1246
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Copyright (c) 2026 Reactor Technologies, Inc. All rights reserved.
|
|
2
|
+
"""Schema-driven validation for inbound event messages.
|
|
3
|
+
|
|
4
|
+
:class:`SchemaValidator` pre-compiles a JSON Schema validator for each
|
|
5
|
+
event in an OpenAPI 3.1 document and provides a single :meth:`validate`
|
|
6
|
+
entry point that validates incoming payloads against the event's request
|
|
7
|
+
body schema.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict, Optional, Set, Tuple
|
|
14
|
+
|
|
15
|
+
import jsonschema
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SchemaValidator:
|
|
21
|
+
"""Pre-compiled JSON Schema validators for a model's events.
|
|
22
|
+
|
|
23
|
+
Accepts a raw OpenAPI 3.1 document (a plain dict). The document
|
|
24
|
+
can come from runtime introspection, a static file, or a registry
|
|
25
|
+
— this class does not depend on how the schema was produced.
|
|
26
|
+
|
|
27
|
+
Instantiate once per session (or model load), then call
|
|
28
|
+
:meth:`validate` for each inbound message.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, openapi: Dict[str, Any]) -> None:
|
|
32
|
+
self._validators: Dict[str, jsonschema.Draft202012Validator] = {}
|
|
33
|
+
self._schemas: Dict[str, Dict[str, Any]] = {}
|
|
34
|
+
self._upload_fields: Dict[str, Set[str]] = {}
|
|
35
|
+
self._moderate_text_fields: Dict[str, Set[str]] = {}
|
|
36
|
+
self._moderate_upload_fields: Dict[str, Set[str]] = {}
|
|
37
|
+
|
|
38
|
+
paths = openapi.get("paths", {})
|
|
39
|
+
components = openapi.get("components", {}).get("schemas", {})
|
|
40
|
+
|
|
41
|
+
for path, path_item in paths.items():
|
|
42
|
+
event_name = path.removeprefix("/events/")
|
|
43
|
+
post = path_item.get("post", {})
|
|
44
|
+
body_content = post.get("requestBody", {}).get("content", {})
|
|
45
|
+
json_content = body_content.get("application/json", {})
|
|
46
|
+
body_schema = json_content.get("schema", {})
|
|
47
|
+
|
|
48
|
+
upload_fields: Set[str] = set()
|
|
49
|
+
moderate_text: Set[str] = set()
|
|
50
|
+
moderate_uploads: Set[str] = set()
|
|
51
|
+
for field_name, field_schema in body_schema.get("properties", {}).items():
|
|
52
|
+
is_upload = _is_upload_reference(field_schema)
|
|
53
|
+
if is_upload:
|
|
54
|
+
upload_fields.add(field_name)
|
|
55
|
+
|
|
56
|
+
# Only fields explicitly opted out via x-reactor-moderate=False
|
|
57
|
+
# are skipped. Any other value (missing key, True, or a stray
|
|
58
|
+
# non-bool from a hand-edited schema) defaults to moderate —
|
|
59
|
+
# safe-by-default on this safety-sensitive path, so a typo
|
|
60
|
+
# like `x-reactor-moderate: 0` doesn't silently disable.
|
|
61
|
+
moderate = field_schema.get("x-reactor-moderate", True)
|
|
62
|
+
if moderate is False:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if is_upload:
|
|
66
|
+
moderate_uploads.add(field_name)
|
|
67
|
+
elif _schema_has_free_text(field_schema, components):
|
|
68
|
+
moderate_text.add(field_name)
|
|
69
|
+
|
|
70
|
+
self._upload_fields[event_name] = upload_fields
|
|
71
|
+
self._moderate_text_fields[event_name] = moderate_text
|
|
72
|
+
self._moderate_upload_fields[event_name] = moderate_uploads
|
|
73
|
+
|
|
74
|
+
resolved = _inline_refs(
|
|
75
|
+
body_schema,
|
|
76
|
+
openapi.get("components", {}).get("schemas", {}),
|
|
77
|
+
)
|
|
78
|
+
self._schemas[event_name] = resolved
|
|
79
|
+
self._validators[event_name] = jsonschema.Draft202012Validator(resolved)
|
|
80
|
+
|
|
81
|
+
def validate(
|
|
82
|
+
self, event_name: str, data: Dict[str, Any]
|
|
83
|
+
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
84
|
+
"""Validate *data* against the schema for *event_name*.
|
|
85
|
+
|
|
86
|
+
Returns ``(data, None)`` on success, or
|
|
87
|
+
``(None, error_message)`` on failure.
|
|
88
|
+
"""
|
|
89
|
+
validator = self._validators.get(event_name)
|
|
90
|
+
if validator is None:
|
|
91
|
+
return None, f"Unknown event: {event_name}"
|
|
92
|
+
|
|
93
|
+
errors = list(validator.iter_errors(data))
|
|
94
|
+
if errors:
|
|
95
|
+
messages = "; ".join(e.message for e in errors[:3])
|
|
96
|
+
return None, f"Validation failed for {event_name}: {messages}"
|
|
97
|
+
|
|
98
|
+
return data, None
|
|
99
|
+
|
|
100
|
+
def get_upload_fields(self, event_name: str) -> Set[str]:
|
|
101
|
+
"""Return field names with ``format: reactor-upload-reference``."""
|
|
102
|
+
return self._upload_fields.get(event_name, set())
|
|
103
|
+
|
|
104
|
+
def get_moderate_text_fields(self, event_name: str) -> Set[str]:
|
|
105
|
+
"""Return free-text ``str`` field names that should be moderated.
|
|
106
|
+
|
|
107
|
+
Free-text means a ``string`` schema without an ``enum`` constraint.
|
|
108
|
+
Fields explicitly opted out via ``InputField(moderate=False)`` are
|
|
109
|
+
excluded.
|
|
110
|
+
"""
|
|
111
|
+
return self._moderate_text_fields.get(event_name, set())
|
|
112
|
+
|
|
113
|
+
def get_moderate_upload_fields(self, event_name: str) -> Set[str]:
|
|
114
|
+
"""Return upload-reference field names that should be moderated.
|
|
115
|
+
|
|
116
|
+
Fields explicitly opted out via ``InputField(moderate=False)`` are
|
|
117
|
+
excluded.
|
|
118
|
+
"""
|
|
119
|
+
return self._moderate_upload_fields.get(event_name, set())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _schema_has_free_text(
|
|
123
|
+
schema: Dict[str, Any],
|
|
124
|
+
components: Dict[str, Any],
|
|
125
|
+
seen: Optional[Set[str]] = None,
|
|
126
|
+
) -> bool:
|
|
127
|
+
"""Return True if *schema* contains any free-text ``string`` leaf.
|
|
128
|
+
|
|
129
|
+
Walks the schema recursively — through ``anyOf`` / ``allOf`` / ``oneOf``
|
|
130
|
+
variants, into ``items`` (array element schemas), into
|
|
131
|
+
``additionalProperties`` (open-ended object value schemas), into
|
|
132
|
+
nested ``properties`` (dataclass-style structured fields), and
|
|
133
|
+
through ``$ref`` (component references). Closed-set ``enum``
|
|
134
|
+
constraints stop the walk because the validator rejects out-of-set
|
|
135
|
+
values before they reach the model, so there's nothing user-
|
|
136
|
+
controlled to moderate.
|
|
137
|
+
|
|
138
|
+
Used at schema discovery to decide whether a top-level field is
|
|
139
|
+
eligible for text moderation. Authors with structured payloads
|
|
140
|
+
(e.g. ``chat_history: List[str]`` or ``turns: List[ChatTurn]``)
|
|
141
|
+
were silently bypassed by the previous top-level-string-only
|
|
142
|
+
check — codex P1 on PR #2533.
|
|
143
|
+
"""
|
|
144
|
+
if seen is None:
|
|
145
|
+
seen = set()
|
|
146
|
+
|
|
147
|
+
# Resolve a $ref before doing anything else; component schemas can
|
|
148
|
+
# be self-referential (rare for OpenAPI bodies, but cheap to guard).
|
|
149
|
+
if "$ref" in schema:
|
|
150
|
+
ref_path = schema["$ref"]
|
|
151
|
+
if ref_path in seen:
|
|
152
|
+
return False
|
|
153
|
+
ref_name = ref_path.rsplit("/", 1)[-1]
|
|
154
|
+
resolved = components.get(ref_name)
|
|
155
|
+
if resolved is None:
|
|
156
|
+
return False
|
|
157
|
+
return _schema_has_free_text(resolved, components, seen | {ref_path})
|
|
158
|
+
|
|
159
|
+
# Closed-set values aren't user-controlled — skip.
|
|
160
|
+
if schema.get("enum") is not None:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
if schema.get("type") == "string":
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
# Union variants (Optional[str], Union[str, int], etc.).
|
|
167
|
+
for keyword in ("anyOf", "allOf", "oneOf"):
|
|
168
|
+
for sub in schema.get(keyword, []):
|
|
169
|
+
if _schema_has_free_text(sub, components, seen):
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
# Array element schema (List[str], List[ChatTurn], ...).
|
|
173
|
+
items = schema.get("items")
|
|
174
|
+
if isinstance(items, dict) and _schema_has_free_text(items, components, seen):
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
# Open-ended objects (Dict[str, str], Dict[str, ChatTurn], ...).
|
|
178
|
+
addl = schema.get("additionalProperties")
|
|
179
|
+
if isinstance(addl, dict) and _schema_has_free_text(addl, components, seen):
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
# Structured objects (dataclass-style fields).
|
|
183
|
+
for sub in schema.get("properties", {}).values():
|
|
184
|
+
if _schema_has_free_text(sub, components, seen):
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_upload_reference(field_schema: Dict[str, Any]) -> bool:
|
|
191
|
+
"""Check if a field schema references ReactorUploadReference.
|
|
192
|
+
|
|
193
|
+
Handles both direct ``$ref`` and ``Optional[UploadedFile]`` which
|
|
194
|
+
wraps the ``$ref`` inside an ``anyOf``.
|
|
195
|
+
"""
|
|
196
|
+
ref = field_schema.get("$ref", "")
|
|
197
|
+
if "ReactorUploadReference" in ref:
|
|
198
|
+
return True
|
|
199
|
+
for sub in field_schema.get("anyOf", []):
|
|
200
|
+
if "ReactorUploadReference" in sub.get("$ref", ""):
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _inline_refs(
|
|
206
|
+
schema: Dict[str, Any], component_schemas: Dict[str, Any]
|
|
207
|
+
) -> Dict[str, Any]:
|
|
208
|
+
"""Recursively resolve ``$ref`` pointers against component schemas."""
|
|
209
|
+
if "$ref" in schema:
|
|
210
|
+
ref_path = schema["$ref"]
|
|
211
|
+
ref_name = ref_path.rsplit("/", 1)[-1]
|
|
212
|
+
resolved = component_schemas.get(ref_name)
|
|
213
|
+
if resolved is None:
|
|
214
|
+
raise ValueError(f"$ref '{ref_path}' not found in component schemas")
|
|
215
|
+
return _inline_refs(resolved, component_schemas)
|
|
216
|
+
|
|
217
|
+
result = dict(schema)
|
|
218
|
+
if "properties" in result:
|
|
219
|
+
result["properties"] = {
|
|
220
|
+
k: _inline_refs(v, component_schemas)
|
|
221
|
+
for k, v in result["properties"].items()
|
|
222
|
+
}
|
|
223
|
+
if "items" in result and isinstance(result["items"], dict):
|
|
224
|
+
result["items"] = _inline_refs(result["items"], component_schemas)
|
|
225
|
+
for keyword in ("anyOf", "allOf", "oneOf"):
|
|
226
|
+
if keyword in result:
|
|
227
|
+
result[keyword] = [
|
|
228
|
+
_inline_refs(v, component_schemas) for v in result[keyword]
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
return result
|
|
@@ -35,6 +35,10 @@ class RuntimeMessageType(str, Enum):
|
|
|
35
35
|
REQUEST_RECORDING = "requestRecording"
|
|
36
36
|
CLIP_READY = "clipReady"
|
|
37
37
|
CLIP_FAILED = "clipFailed"
|
|
38
|
+
# Content moderation events (warn / terminate) emitted to the client
|
|
39
|
+
# when a moderatable input is flagged. Carries the action tier,
|
|
40
|
+
# categories, and a short human-readable message.
|
|
41
|
+
MODERATION = "moderation"
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
class ApplicationMessage(BaseModel):
|