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.
Files changed (140) hide show
  1. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/PKG-INFO +1 -1
  2. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/pyproject.toml +1 -1
  3. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/config.py +36 -1
  4. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/defaults.py +12 -0
  5. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtime_api.py +252 -1
  6. reactor_runtime-2.7.8/src/reactor_runtime/schema_validator.py +231 -0
  7. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/messages.py +4 -0
  8. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/PKG-INFO +1 -1
  9. reactor_runtime-2.7.7/src/reactor_runtime/schema_validator.py +0 -123
  10. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/README.md +0 -0
  11. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/setup.cfg +0 -0
  12. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/api/__init__.py +0 -0
  13. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/__init__.py +0 -0
  14. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/experiment/__init__.py +0 -0
  15. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/experiment/session.py +0 -0
  16. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/__init__.py +0 -0
  17. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/driver/__init__.py +0 -0
  18. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/driver/pipeline_executor.py +0 -0
  19. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/driver/step_result.py +0 -0
  20. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/__init__.py +0 -0
  21. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/connected.py +0 -0
  22. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/event.py +0 -0
  23. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/messages.py +0 -0
  24. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/events/upload.py +0 -0
  25. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  26. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  27. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/output_buffer.py +0 -0
  28. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  29. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/__init__.py +0 -0
  30. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/decorators.py +0 -0
  31. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/handlers.py +0 -0
  32. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  33. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  34. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  35. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  36. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  37. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  38. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  39. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/input.py +0 -0
  40. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/tracks/output.py +0 -0
  41. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/interface/upload.py +0 -0
  42. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/model_state.py +0 -0
  43. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/__init__.py +0 -0
  44. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/__init__.py +0 -0
  45. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/base.py +0 -0
  46. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/file.py +0 -0
  47. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/backends/otlp.py +0 -0
  48. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/helpers.py +0 -0
  49. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/nvml_sampler.py +0 -0
  50. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/plotting/__init__.py +0 -0
  51. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/plotting/plot_profiling.py +0 -0
  52. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/profiler.py +0 -0
  53. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/singleton.py +0 -0
  54. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/profiling/torch_chunk_profiler.py +0 -0
  55. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/__init__.py +0 -0
  56. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  57. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/chunk_uploader.py +0 -0
  58. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/config.py +0 -0
  59. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/markers.py +0 -0
  60. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/session_recorder.py +0 -0
  61. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/sinks.py +0 -0
  62. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/recording/track_resolver.py +0 -0
  63. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/headless/config.py +0 -0
  64. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/headless/headless_runtime.py +0 -0
  65. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/headless/input_feeder.py +0 -0
  66. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/http/config.py +0 -0
  67. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/http/http_runtime.py +0 -0
  68. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/runtimes/http/types.py +0 -0
  69. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/schema.py +0 -0
  70. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/__init__.py +0 -0
  71. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/__main__.py +0 -0
  72. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/commands/__init__.py +0 -0
  73. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/commands/run.py +0 -0
  74. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/commands/schema.py +0 -0
  75. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/main.py +0 -0
  76. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/utils/__init__.py +0 -0
  77. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/utils/config.py +0 -0
  78. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/serve/utils/runtime.py +0 -0
  79. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/__init__.py +0 -0
  80. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/__init__.py +0 -0
  81. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/audio_track.py +0 -0
  82. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/client.py +0 -0
  83. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/frame_conversion.py +0 -0
  84. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/ice_connection.py +0 -0
  85. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/aiortc/video_track.py +0 -0
  86. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/config.py +0 -0
  87. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/events.py +0 -0
  88. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/__init__.py +0 -0
  89. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/client.py +0 -0
  90. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/__init__.py +0 -0
  91. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/av1.py +0 -0
  92. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/base.py +0 -0
  93. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/factory.py +0 -0
  94. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/h264.py +0 -0
  95. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/h265.py +0 -0
  96. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/opus.py +0 -0
  97. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/vp8.py +0 -0
  98. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/decoders/vp9.py +0 -0
  99. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/__init__.py +0 -0
  100. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/av1.py +0 -0
  101. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/base.py +0 -0
  102. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/factory.py +0 -0
  103. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/h264.py +0 -0
  104. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/h265.py +0 -0
  105. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/opus.py +0 -0
  106. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/vp8.py +0 -0
  107. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/encoders/vp9.py +0 -0
  108. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/gst.py +0 -0
  109. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/gst_helpers.py +0 -0
  110. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/probes/__init__.py +0 -0
  111. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/probes/fps_probe.py +0 -0
  112. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/__init__.py +0 -0
  113. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/audio.py +0 -0
  114. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/base.py +0 -0
  115. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/receiver/video.py +0 -0
  116. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/__init__.py +0 -0
  117. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/bundle.py +0 -0
  118. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/codec.py +0 -0
  119. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/extmap.py +0 -0
  120. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sdp/ice.py +0 -0
  121. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/__init__.py +0 -0
  122. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/audio.py +0 -0
  123. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/base.py +0 -0
  124. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/sender/video.py +0 -0
  125. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/settings.py +0 -0
  126. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/gstreamer/signals.py +0 -0
  127. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/ice_uris.py +0 -0
  128. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/interface.py +0 -0
  129. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/media.py +0 -0
  130. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/transports/types.py +0 -0
  131. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/launch.py +0 -0
  132. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/loader.py +0 -0
  133. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/log.py +0 -0
  134. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/paths.py +0 -0
  135. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/ports.py +0 -0
  136. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime/utils/typing.py +0 -0
  137. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/SOURCES.txt +0 -0
  138. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/dependency_links.txt +0 -0
  139. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/requires.txt +0 -0
  140. {reactor_runtime-2.7.7 → reactor_runtime-2.7.8}/src/reactor_runtime.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor_runtime
3
- Version: 2.7.7
3
+ Version: 2.7.8
4
4
  Summary: Reactor runtime with public model API
5
5
  Author-email: Reactor <team@reactor.inc>
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactor_runtime"
7
- version = "2.7.7"
7
+ version = "2.7.8"
8
8
  description = "Reactor runtime with public model API"
9
9
  authors = [
10
10
  { name = "Reactor", email = "team@reactor.inc" }
@@ -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 is a top-level sibling to runtime: / model:.
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):